소스 검색

新增客户部分代码

YPZ 1 주 전
부모
커밋
89b6b7cdd6

+ 5 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/dto/CustomerDTO.java

@@ -24,6 +24,9 @@ public class CustomerDTO extends Page {
     @ApiModelProperty(value = "客户名称(模糊查询)")
     private String customerName;
 
+    @ApiModelProperty(value = "模糊查询关键字(覆盖公司名称、联系人姓名)")
+    private String blurry;
+
     @ApiModelProperty(value = "客户编码")
     private String customerCode;
 
@@ -68,9 +71,11 @@ public class CustomerDTO extends Page {
 
     // ===== 联表筛选(来自关联表) =====
     @ApiModelProperty(value = "公司名称(模糊查询,来自customer_company表)")
+    @Deprecated
     private String companyName;
 
     @ApiModelProperty(value = "联系人姓名(模糊查询,来自liaison表)")
+    @Deprecated
     private String liaisonName;
 
     @ApiModelProperty(value = "联系人手机号(精确查询,来自liaison表)")

+ 1 - 10
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/mapper/CustomerEntityMapper.java

@@ -8,8 +8,6 @@ import com.storlead.trade.vo.CustomerVO;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
-import java.util.List;
-
 @Mapper
 public interface CustomerEntityMapper extends TradeBaseMapper<CustomerEntity> {
 
@@ -17,7 +15,7 @@ public interface CustomerEntityMapper extends TradeBaseMapper<CustomerEntity> {
      * 联表分页查询客户(含企业信息+首要联系人)
      * @param page MyBatis-Plus Page(自动注入)
      * @param dto  查询条件
-     * @return 分页结果
+     * @return 分页结果,MyBatis resultMap 自动映射嵌套对象
      */
     IPage<CustomerVO> selectPageWithRelation(IPage<CustomerVO> page, @Param("dto") CustomerDTO dto);
 
@@ -27,11 +25,4 @@ public interface CustomerEntityMapper extends TradeBaseMapper<CustomerEntity> {
      * @return 客户详情VO
      */
     CustomerVO selectDetailByCustomerId(@Param("customerId") Long customerId);
-
-    /**
-     * 批量查询客户ID对应的首要联系人
-     * @param customerIds 客户ID列表
-     * @return 联系人列表
-     */
-    List<CustomerVO> selectPrimaryLiaisonByCustomerIds(@Param("customerIds") List<Long> customerIds);
 }

+ 8 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/CustomerCompanyTradeService.java

@@ -5,10 +5,18 @@ import com.storlead.trade.entity.CustomerCompanyEntity;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.mybatis.service.MyBaseService;
 
+import java.util.List;
+import java.util.Map;
+
 public interface CustomerCompanyTradeService extends MyBaseService<CustomerCompanyEntity> {
 
     Result<Object> getList(CustomerCompanyDTO dto);
 
+    /**
+     * 根据客户ID列表批量查询企业信息,返回 customerId → 企业实体 Map
+     */
+    Map<Long, CustomerCompanyEntity> customerIdListToCompanyMap(List<Long> customerIds);
+
     Result<Object> add(CustomerCompanyDTO dto);
 
     Result<Object> edit(CustomerCompanyDTO dto);

+ 8 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/LiaisonTradeService.java

@@ -5,10 +5,18 @@ import com.storlead.trade.entity.LiaisonEntity;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.mybatis.service.MyBaseService;
 
+import java.util.List;
+import java.util.Map;
+
 public interface LiaisonTradeService extends MyBaseService<LiaisonEntity> {
 
     Result<Object> getList(LiaisonDTO dto);
 
+    /**
+     * 根据客户ID列表批量查询首要联系人,返回 customerId → 联系人实体 Map
+     */
+    Map<Long, LiaisonEntity> customerIdListToLiaisonMap(List<Long> customerIds);
+
     Result<Object> add(LiaisonDTO dto);
 
     Result<Object> edit(LiaisonDTO dto);

+ 21 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/CustomerCompanyTradeServiceImpl.java

@@ -20,6 +20,8 @@ import org.springframework.util.ObjectUtils;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 @Service
 @DS(DSConstants.DATASOURCE_TRADE)
@@ -61,6 +63,25 @@ public class CustomerCompanyTradeServiceImpl
         return Result.result(pageVO);
     }
 
+    @Override
+    public Map<Long, CustomerCompanyEntity> customerIdListToCompanyMap(List<Long> customerIds) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return new java.util.HashMap<>();
+        }
+        LambdaQueryWrapper<CustomerCompanyEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(CustomerCompanyEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        wrapper.in(CustomerCompanyEntity::getCustomerId, customerIds);
+        List<CustomerCompanyEntity> list = this.list(wrapper);
+        if (list == null || list.isEmpty()) {
+            return new java.util.HashMap<>();
+        }
+        return list.stream().collect(Collectors.toMap(
+                CustomerCompanyEntity::getCustomerId,
+                e -> e,
+                (v1, v2) -> v1
+        ));
+    }
+
     @Override
     @Transactional(rollbackFor = Throwable.class)
     public Result<Object> add(CustomerCompanyDTO dto) {

+ 191 - 7
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/CustomerTradeServiceImpl.java

@@ -1,24 +1,33 @@
 package com.storlead.trade.service.impl;
 
 import com.baomidou.dynamic.datasource.annotation.DS;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.common.constant.DSConstants;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.trade.dto.CustomerDTO;
+import com.storlead.trade.entity.CustomerCompanyEntity;
 import com.storlead.trade.entity.CustomerEntity;
+import com.storlead.trade.entity.LiaisonEntity;
 import com.storlead.trade.mapper.CustomerEntityMapper;
+import com.storlead.trade.service.CustomerCompanyTradeService;
 import com.storlead.trade.service.CustomerTradeService;
+import com.storlead.trade.service.LiaisonTradeService;
 import com.storlead.trade.vo.CustomerVO;
 import org.springframework.beans.BeanUtils;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
+import org.springframework.util.StringUtils;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 @Service
 @DS(DSConstants.DATASOURCE_TRADE)
@@ -26,15 +35,174 @@ public class CustomerTradeServiceImpl
         extends MyBaseServiceImpl<CustomerEntityMapper, CustomerEntity>
         implements CustomerTradeService {
 
+    private final CustomerCompanyTradeService customerCompanyTradeService;
+    private final LiaisonTradeService liaisonTradeService;
+
+    public CustomerTradeServiceImpl(
+            CustomerCompanyTradeService customerCompanyTradeService,
+            LiaisonTradeService liaisonTradeService) {
+        this.customerCompanyTradeService = customerCompanyTradeService;
+        this.liaisonTradeService = liaisonTradeService;
+    }
+
     @Override
     public IPage<CustomerVO> getList(CustomerDTO dto) {
-        Page<CustomerVO> page = new Page<>(dto.getPageIndex(), dto.getPageSize());
-        IPage<CustomerVO> result = baseMapper.selectPageWithRelation(page, dto);
-        if (result == null || CollectionUtils.isEmpty(result.getRecords())) {
-            result = new Page<>(dto.getPageIndex(), dto.getPageSize());
-            result.setRecords(new ArrayList<>());
+        // Step1: 单表分页查客户
+        Page<CustomerEntity> page = new Page<>(dto.getPageIndex(), dto.getPageSize());
+        LambdaQueryWrapper<CustomerEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(CustomerEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+
+        // 精确筛选
+        if (!ObjectUtils.isEmpty(dto.getId())) {
+            wrapper.eq(CustomerEntity::getId, dto.getId());
+        }
+        if (!ObjectUtils.isEmpty(dto.getCustomerCode())) {
+            wrapper.eq(CustomerEntity::getCustomerCode, dto.getCustomerCode());
+        }
+        if (!ObjectUtils.isEmpty(dto.getCustomerForm())) {
+            wrapper.eq(CustomerEntity::getCustomerForm, dto.getCustomerForm());
+        }
+        if (!ObjectUtils.isEmpty(dto.getCustomerStatusDictValue())) {
+            wrapper.eq(CustomerEntity::getCustomerStatusDictValue, dto.getCustomerStatusDictValue());
+        }
+        if (!ObjectUtils.isEmpty(dto.getCustomerTypeDictValue())) {
+            wrapper.eq(CustomerEntity::getCustomerTypeDictValue, dto.getCustomerTypeDictValue());
+        }
+        if (!ObjectUtils.isEmpty(dto.getCustomerSourceDictValue())) {
+            wrapper.eq(CustomerEntity::getCustomerSourceDictValue, dto.getCustomerSourceDictValue());
+        }
+        if (!ObjectUtils.isEmpty(dto.getCustomerLevelDictValue())) {
+            wrapper.eq(CustomerEntity::getCustomerLevelDictValue, dto.getCustomerLevelDictValue());
+        }
+        if (!ObjectUtils.isEmpty(dto.getCustomerCreditLevelDictValue())) {
+            wrapper.eq(CustomerEntity::getCustomerCreditLevelDictValue, dto.getCustomerCreditLevelDictValue());
         }
-        return result;
+        if (!ObjectUtils.isEmpty(dto.getCustomerEconomicNatureDictValue())) {
+            wrapper.eq(CustomerEntity::getCustomerEconomicNatureDictValue, dto.getCustomerEconomicNatureDictValue());
+        }
+        if (!ObjectUtils.isEmpty(dto.getHasFollowCustomer())) {
+            wrapper.eq(CustomerEntity::getHasFollowCustomer, dto.getHasFollowCustomer());
+        }
+        if (!ObjectUtils.isEmpty(dto.getHasPublicCustomer())) {
+            wrapper.eq(CustomerEntity::getHasPublicCustomer, dto.getHasPublicCustomer());
+        }
+        if (!ObjectUtils.isEmpty(dto.getHasGarbageCustomer())) {
+            wrapper.eq(CustomerEntity::getHasGarbageCustomer, dto.getHasGarbageCustomer());
+        }
+        if (!ObjectUtils.isEmpty(dto.getHasOrderForm())) {
+            wrapper.eq(CustomerEntity::getHasOrderForm, dto.getHasOrderForm());
+        }
+        if (!ObjectUtils.isEmpty(dto.getContinent())) {
+            wrapper.eq(CustomerEntity::getContinent, dto.getContinent());
+        }
+        if (!ObjectUtils.isEmpty(dto.getCountry())) {
+            wrapper.eq(CustomerEntity::getCountry, dto.getCountry());
+        }
+
+        // 模糊筛选:客户名称
+        if (!StringUtils.isEmpty(dto.getCustomerName())) {
+            wrapper.like(CustomerEntity::getCustomerName, dto.getCustomerName());
+        }
+
+        // 模糊筛选:blurry(覆盖公司名称 + 联系人姓名)
+        // 思路:分别查 customer_company / liaison 两张表取 customerId,做并集后再 IN customer 表
+        if (!StringUtils.isEmpty(dto.getBlurry())) {
+            String blurry = dto.getBlurry().trim();
+
+            List<Long> companyCustomerIds = customerCompanyTradeService.list(
+                    new LambdaQueryWrapper<CustomerCompanyEntity>()
+                            .eq(CustomerCompanyEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                            .like(CustomerCompanyEntity::getName, blurry)
+            ).stream().map(CustomerCompanyEntity::getCustomerId).collect(Collectors.toList());
+
+            List<Long> liaisonCustomerIds = liaisonTradeService.list(
+                    new LambdaQueryWrapper<LiaisonEntity>()
+                            .eq(LiaisonEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                            .eq(LiaisonEntity::getHasPrimaryLialison, 1)
+                            .like(LiaisonEntity::getName, blurry)
+            ).stream().map(LiaisonEntity::getCustomerId).collect(Collectors.toList());
+
+            // 合并两个集合,去重
+            List<Long> blurCustomerIds = new ArrayList<>();
+            blurCustomerIds.addAll(companyCustomerIds);
+            blurCustomerIds.addAll(liaisonCustomerIds);
+            blurCustomerIds = blurCustomerIds.stream().distinct().collect(Collectors.toList());
+
+            if (CollectionUtils.isEmpty(blurCustomerIds)) {
+                // 无匹配公司也无匹配联系人,直接返回空
+                Page<CustomerVO> emptyPage = new Page<>(dto.getPageIndex(), dto.getPageSize());
+                emptyPage.setRecords(new ArrayList<>());
+                return emptyPage;
+            }
+            wrapper.in(CustomerEntity::getId, blurCustomerIds);
+        }
+
+        // 精确筛选:联系人手机/邮箱
+        if (!StringUtils.isEmpty(dto.getLiaisonTelephone())) {
+            List<Long> liaisonCustomerIds = liaisonTradeService.list(
+                    new LambdaQueryWrapper<LiaisonEntity>()
+                            .eq(LiaisonEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                            .eq(LiaisonEntity::getHasPrimaryLialison, 1)
+                            .eq(LiaisonEntity::getTelephone, dto.getLiaisonTelephone())
+            ).stream().map(LiaisonEntity::getCustomerId).collect(Collectors.toList());
+            if (CollectionUtils.isEmpty(liaisonCustomerIds)) {
+                Page<CustomerVO> emptyPage = new Page<>(dto.getPageIndex(), dto.getPageSize());
+                emptyPage.setRecords(new ArrayList<>());
+                return emptyPage;
+            }
+            wrapper.in(CustomerEntity::getId, liaisonCustomerIds);
+        }
+        if (!StringUtils.isEmpty(dto.getLiaisonEmail())) {
+            List<Long> liaisonCustomerIds = liaisonTradeService.list(
+                    new LambdaQueryWrapper<LiaisonEntity>()
+                            .eq(LiaisonEntity::getIsDelete, CommonConstant.DEL_FLAG_0)
+                            .eq(LiaisonEntity::getHasPrimaryLialison, 1)
+                            .eq(LiaisonEntity::getEmail, dto.getLiaisonEmail())
+            ).stream().map(LiaisonEntity::getCustomerId).collect(Collectors.toList());
+            if (CollectionUtils.isEmpty(liaisonCustomerIds)) {
+                Page<CustomerVO> emptyPage = new Page<>(dto.getPageIndex(), dto.getPageSize());
+                emptyPage.setRecords(new ArrayList<>());
+                return emptyPage;
+            }
+            wrapper.in(CustomerEntity::getId, liaisonCustomerIds);
+        }
+
+        wrapper.orderByDesc(CustomerEntity::getCreateTime);
+        IPage<CustomerEntity> resultPage = this.page(page, wrapper);
+
+        if (resultPage == null || CollectionUtils.isEmpty(resultPage.getRecords())) {
+            Page<CustomerVO> emptyPage = new Page<>(dto.getPageIndex(), dto.getPageSize());
+            emptyPage.setRecords(new ArrayList<>());
+            return emptyPage;
+        }
+
+        // Step2: 收集客户ID,批量查询企业信息+联系人
+        List<Long> customerIds = resultPage.getRecords().stream()
+                .map(CustomerEntity::getId)
+                .collect(Collectors.toList());
+
+        Map<Long, CustomerCompanyEntity> companyMap =
+                customerCompanyTradeService.customerIdListToCompanyMap(customerIds);
+        Map<Long, LiaisonEntity> liaisonMap =
+                liaisonTradeService.customerIdListToLiaisonMap(customerIds);
+
+        // Step3: 组装 VO
+        List<CustomerVO> voList = new ArrayList<>();
+        for (CustomerEntity entity : resultPage.getRecords()) {
+            CustomerVO vo = new CustomerVO();
+            BeanUtils.copyProperties(entity, vo);
+            vo.setCustomerCompany(companyMap.get(entity.getId()));
+            vo.setLiaison(liaisonMap.get(entity.getId()));
+            voList.add(vo);
+        }
+
+        // Step4: 组装返回分页
+        Page<CustomerVO> voPage = new Page<>();
+        voPage.setRecords(voList);
+        voPage.setTotal(resultPage.getTotal());
+        voPage.setCurrent(resultPage.getCurrent());
+        voPage.setSize(resultPage.getSize());
+        return voPage;
     }
 
     @Override
@@ -42,7 +210,23 @@ public class CustomerTradeServiceImpl
         if (customerId == null) {
             return null;
         }
-        return baseMapper.selectDetailByCustomerId(customerId);
+        CustomerEntity entity = this.getById(customerId);
+        if (entity == null) {
+            return null;
+        }
+        CustomerVO vo = new CustomerVO();
+        BeanUtils.copyProperties(entity, vo);
+
+        Map<Long, CustomerCompanyEntity> companyMap =
+                customerCompanyTradeService.customerIdListToCompanyMap(
+                        java.util.Collections.singletonList(customerId));
+        Map<Long, LiaisonEntity> liaisonMap =
+                liaisonTradeService.customerIdListToLiaisonMap(
+                        java.util.Collections.singletonList(customerId));
+
+        vo.setCustomerCompany(companyMap.get(customerId));
+        vo.setLiaison(liaisonMap.get(customerId));
+        return vo;
     }
 
     @Override

+ 23 - 0
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/LiaisonTradeServiceImpl.java

@@ -1,6 +1,7 @@
 package com.storlead.trade.service.impl;
 
 import com.baomidou.dynamic.datasource.annotation.DS;
+import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.common.constant.DSConstants;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
@@ -18,6 +19,8 @@ import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
 
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 @Service
 @DS(DSConstants.DATASOURCE_TRADE)
@@ -51,6 +54,26 @@ public class LiaisonTradeServiceImpl extends MyBaseServiceImpl<LiaisonEntityMapp
         return Result.result(pageList);
     }
 
+    @Override
+    public Map<Long, LiaisonEntity> customerIdListToLiaisonMap(List<Long> customerIds) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return new java.util.HashMap<>();
+        }
+        LambdaQueryWrapper<LiaisonEntity> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(LiaisonEntity::getIsDelete, CommonConstant.DEL_FLAG_0);
+        wrapper.eq(LiaisonEntity::getHasPrimaryLialison, 1);
+        wrapper.in(LiaisonEntity::getCustomerId, customerIds);
+        List<LiaisonEntity> list = this.list(wrapper);
+        if (list == null || list.isEmpty()) {
+            return new java.util.HashMap<>();
+        }
+        return list.stream().collect(Collectors.toMap(
+                LiaisonEntity::getCustomerId,
+                e -> e,
+                (v1, v2) -> v1
+        ));
+    }
+
     @Override
     @Transactional(rollbackFor = Throwable.class)
     public Result<Object> add(LiaisonDTO dto) {

+ 381 - 39
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/service/impl/MarketEmailsServiceImpl.java

@@ -1,14 +1,21 @@
 package com.storlead.trade.service.impl;
 
+import cn.hutool.core.date.DateTime;
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.TypeReference;
+import com.alibaba.fastjson.parser.Feature;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.storlead.framework.common.constant.CommonConstant;
 import com.storlead.framework.common.result.Result;
 import com.storlead.framework.mybatis.service.impl.MyBaseServiceImpl;
 import com.storlead.knowledge.pojo.dto.ChatDTO;
 import com.storlead.knowledge.service.ChatService;
+import com.storlead.knowledge.service.WorkflowsService;
 import com.storlead.trade.dto.MarketEmailsDTO;
 import com.storlead.trade.entity.*;
 import com.storlead.trade.mapper.MarketEmailsMapper;
@@ -23,12 +30,20 @@ import org.springframework.util.ObjectUtils;
 
 import javax.annotation.Resource;
 import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 @Service
 public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMapper, MarketEmailsEntity>
         implements MarketEmailsService {
 
+    private static final org.slf4j.Logger log =
+            org.slf4j.LoggerFactory.getLogger(MarketEmailsServiceImpl.class);
+
+    /** AI workflow 单次排期最多重试次数(应对模型思考被截断、未吐出 JSON 的情况) */
+    private static final int WORKFLOW_MAX_RETRY = 3;
+
     @Resource
     private CustomerBaseService customerBaseService;
 
@@ -42,7 +57,7 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
     @Resource
     private SopDetailService sopDetailService;
     @Resource
-    private ChatService chatService;
+    private WorkflowsService workflowsService;
 
 
     @Override
@@ -196,26 +211,24 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
             String country = entry.getKey();
             List<MarketEmailsCustomerVO> customerVOList = entry.getValue();
 
+            //sopDetailEntityList转为String字符串
+            String sopDetailEntityString = JSON.toJSONString(sopDetailEntityList);
+
             Map sendMap = new HashMap();
-            sendMap.put("conuntry", country);
-            sendMap.put("steps", sopDetailEntityList);
+            sendMap.put("country", country);
+            sendMap.put("steps", sopDetailEntityString);
 
             ChatDTO chatDTO = new ChatDTO();
             chatDTO.setInputs(sendMap);
-            chatDTO.setQuery("send_time");
+//            chatDTO.setQuery("send_time");
             chatDTO.setResponseMode("blocking");
             chatDTO.setAppId("app-USCuwcmrJi3SwwSz64viUczH");
             chatDTO.setUser("1");
 
-            //调取/router/rest/workflows/streaming接口获取时间
-            Result<Object> result = chatService.requestAnalysisToAi(chatDTO);
-            if (result == null || !result.isSuccess() || result.getResult() == null) {
-                continue;
-            }
-
-            // 解析 AI 返回的调度阶段(不同国家)
-            List<ScheduleStageVO> scheduleList = parseScheduleStages(result.getResult());
+            // 调取 AI workflow 获取时间(带重试:应对思考被截断导致无 JSON 的情况)
+            List<ScheduleStageVO> scheduleList = fetchScheduleStagesWithRetry(chatDTO);
             if (CollectionUtils.isEmpty(scheduleList)) {
+                log.warn("国家[{}] 的 AI 排期获取失败(重试{}次后仍无有效结果),跳过", country, WORKFLOW_MAX_RETRY);
                 continue;
             }
             // 按 sequence 升序(1,2,3,4),与 sopDetailEntityList 按 step 顺次对齐
@@ -244,6 +257,11 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
 
         }
         if (!marketEmailList.isEmpty()){
+            //删除原有营销关联的邮件信息
+            LambdaUpdateWrapper<MarketEmailsEntity> updateWrapper = new LambdaUpdateWrapper<>();
+            updateWrapper.eq(MarketEmailsEntity::getMarketingCampaignId, marketingCampaignEntity.getId());
+            updateWrapper.set(MarketEmailsEntity::getIsDelete, CommonConstant.DEL_FLAG_1);
+            this.update(updateWrapper);
             this.saveBatch(marketEmailList);
         }
 
@@ -251,44 +269,368 @@ public class MarketEmailsServiceImpl extends MyBaseServiceImpl<MarketEmailsMappe
     }
 
     /**
-     * 解析 AI 返回的调度阶段。返回值可能是 Map / JSON 字符串 / 列表,均统一转为 List<ScheduleStageVO>。
+     * 调用 AI workflow 并解析调度阶段,带重试。
+     * 入参 chatDTO 经 workflowsService.AiWorkflows 返回 Result<Object>,
+     * 这里按需求用 .toString() 以字符串接收,再从中截取调度 JSON。
+     * 触发重试的条件:
+     *  1) workflow 调用返回 null;
+     *  2) 解析结果为空,但原始字符串含 <think> 标记 —— 说明模型思考被截断、未产出 JSON,可重试。
+     * 若解析结果为空且无 <think>(完全无内容),则直接放弃(重试无意义)。
      */
-    @SuppressWarnings("unchecked")
-    private List<ScheduleStageVO> parseScheduleStages(Object aiResult) {
-        try {
-            // 1) 已经是 List
-            if (aiResult instanceof List) {
-                return JSON.parseArray(JSON.toJSONString(aiResult), ScheduleStageVO.class);
+    private List<ScheduleStageVO> fetchScheduleStagesWithRetry(ChatDTO chatDTO) {
+        for (int attempt = 1; attempt <= WORKFLOW_MAX_RETRY; attempt++) {
+            Result<Object> r = workflowsService.AiWorkflows(chatDTO);
+            if (r == null) {
+                log.warn("AI workflow 调用返回 null(第{}次)", attempt);
+                continue;
             }
-            // 2) 是 Map(常见:Map.of("answer", jsonString) 或 Map.of("data", Map.of("answer",...)))
-            if (aiResult instanceof Map) {
-                Map<String, Object> map = (Map<String, Object>) aiResult;
-                Object answer = firstNonNull(map.get("answer"), map.get("data"));
-                if (answer instanceof Map) {
-                    answer = firstNonNull(((Map<String, Object>) answer).get("answer"),
-                            ((Map<String, Object>) answer).get("output"));
+            // 从结构化结果里取出 AI 真正输出的 text 字段(多个结束节点拼接)。
+            // 不再做 inner.toString() 兜底——整串 Result 里有 files=[]/inputs=[] 等空数组
+            // 会被误当成 AI 输出的 JSON 数组(如 [models]),污染解析。
+            String text = extractCleanText(r.getResult());
+            if (text == null || text.isEmpty()) {
+                log.warn("AI workflow 未返回 text(第{}次),data 结构={}",
+                        attempt, summarizeResult(r.getResult()));
+                // 判为“完全无内容”,重试无意义
+                break;
+            }
+            List<ScheduleStageVO> scheduleList = parseScheduleStagesFromText(text);
+            if (!CollectionUtils.isEmpty(scheduleList)) {
+                if (attempt > 1) {
+                    log.info("AI workflow 第{}次重试成功,国家排期已解析", attempt);
                 }
-                if (answer == null) {
-                    return Collections.emptyList();
+                return scheduleList;
+            }
+            // 解析为空:判断"思考被截断"(有 <think> 但无 JSON),可重试
+            if (text.contains("<think>")) {
+                log.warn("AI workflow 返回不完整(第{}次:仅含<think>无JSON),准备重试", attempt);
+                continue;
+            }
+            // 解析失败但 text 存在:可能是模型输出垃圾。重试还有机会能拿到正确结果。
+            log.warn("AI workflow 返回无法解析(第{}次),准备重试", attempt);
+        }
+        return Collections.emptyList();
+    }
+
+    /**
+     * 仅打印 Result 顶层字段名(不打印值),用于定位 Dify 实际返回结构。
+     */
+    private String summarizeResult(Object aiResult) {
+        if (aiResult == null) {
+            return "null";
+        }
+        if (aiResult instanceof Map) {
+            Map<?, ?> map = (Map<?, ?>) aiResult;
+            StringBuilder sb = new StringBuilder("{");
+            boolean first = true;
+            for (Map.Entry<?, ?> e : map.entrySet()) {
+                if (!first) sb.append(", ");
+                first = false;
+                Object v = e.getValue();
+                sb.append(e.getKey()).append('=');
+                if (v instanceof Map) {
+                    sb.append("{").append(((Map<?, ?>) v).keySet()).append('}');
+                } else if (v instanceof List) {
+                    sb.append("List[").append(((List<?>) v).size()).append(']');
+                } else if (v != null && v.toString().length() > 50) {
+                    sb.append(v.toString().substring(0, 50)).append("...");
+                } else {
+                    sb.append(v);
                 }
-                String json = answer.toString();
-                return JSON.parseArray(json, ScheduleStageVO.class);
             }
-            // 3) 是 JSON 字符串
-            String s = aiResult.toString();
-            if (s.trim().startsWith("[")) {
-                return JSON.parseArray(s, ScheduleStageVO.class);
+            sb.append('}');
+            return sb.toString();
+        }
+        String s = aiResult.toString();
+        return s.length() > 200 ? s.substring(0, 200) + "..." : s;
+    }
+
+    /**
+     * 从 workflow 结果中取 AI 输出文本 text。
+     * Dify workflow run 的实际响应结构是:
+     *   {
+     *     "task_id": "...",
+     *     "workflow_run_id": "...",
+     *     "data": {
+     *       "id": "...",
+     *       "workflow_id": "...",
+     *       "status": "succeeded",
+     *       "outputs": {                        // 旧式 workflow(带结束节点返回 text)
+     *         "text": "..."
+     *       }
+     *       或
+     *       "outputs": [                        // 新式 workflow(多个结束节点,每个是一个 {id, files=[], text})
+     *         {"id":"...","files":[],"text":"..."},
+     *         {"id":"...","files":[],"text":"..."}
+     *       ]
+     *     }
+     *   }
+     * 只取 "text" 字段,拼接多个结束节点的 text;其他字段(files/task_id/inputs 等)一律不取,
+     * 避免它们里的空数组被误当成 AI 输出的 JSON 数组。
+     */
+    @SuppressWarnings("unchecked")
+    private String extractCleanText(Object aiResult) {
+        if (aiResult == null) {
+            return null;
+        }
+        Map<String, Object> root;
+        if (aiResult instanceof Map) {
+            root = (Map<String, Object>) aiResult;
+        } else if (aiResult.toString().trim().startsWith("{")) {
+            root = JSON.parseObject(aiResult.toString());
+        } else {
+            return null;
+        }
+        Object data = root.get("data");
+        if (!(data instanceof Map)) {
+            return null;
+        }
+        Map<String, Object> dataMap = (Map<String, Object>) data;
+        Object outputs = dataMap.get("outputs");
+        if (outputs instanceof Map) {
+            // 旧式结构:outputs 是 { text: "..." }
+            Object t = ((Map<String, Object>) outputs).get("text");
+            return t == null ? null : t.toString();
+        }
+        if (outputs instanceof List) {
+            // 新式结构:outputs 是 [{id, files=[], text}, ...] —— 拼接所有 text
+            StringBuilder sb = new StringBuilder();
+            for (Object item : (List<?>) outputs) {
+                if (item instanceof Map) {
+                    Object t = ((Map<String, Object>) item).get("text");
+                    if (t != null) {
+                        if (sb.length() > 0) {
+                            sb.append('\n');
+                        }
+                        sb.append(t.toString());
+                    }
+                }
             }
+            return sb.length() == 0 ? null : sb.toString();
+        }
+        // 兜底:data.text 或顶层 text
+        if (dataMap.get("text") != null) {
+            return dataMap.get("text").toString();
+        }
+        Object t = root.get("text");
+        return t == null ? null : t.toString();
+    }
+
+    /**
+     * 从 AI 文本中解析排期阶段列表。
+     * 兼容:思考块包裹、json 代码块、裸数组、全角标点/括号,以及模型把输出数组重复拼接的情况。
+     */
+    private List<ScheduleStageVO> parseScheduleStagesFromText(String text) {
+        if (text == null || text.isEmpty()) {
             return Collections.emptyList();
+        }
+        // 先整体修复全角标点/括号,再抽取候选数组逐个解析,返回第一个成功解析的非空列表
+        String repaired = repairJson(text);
+        // 诊断:把 text 头和所有候选都打出来,下次报错能直接看到底拿到了什么
+        if (log.isDebugEnabled()) {
+            int show = Math.min(repaired.length(), 300);
+            log.debug("AI 排期 raw text 头 textLen={} head={}", repaired.length(),
+                    repaired.substring(0, show));
+        }
+        List<String> candidates = extractJsonCandidates(repaired);
+        if (log.isDebugEnabled()) {
+            log.debug("AI 排期 抽取到 {} 个候选数组", candidates.size());
+            for (int i = 0; i < candidates.size(); i++) {
+                String c = candidates.get(i);
+                int show = Math.min(c.length(), 200);
+                log.debug("  候选[{}] len={} head={}", i, c.length(), c.substring(0, show));
+            }
+        }
+        for (String cand : candidates) {
+            // 先试 Jackson(标准严格,识别控制字符,不"自作聪明"补引号)
+            List<ScheduleStageVO> list = parseStagesWithJackson(cand);
+            if (list != null && !list.isEmpty()) {
+                return list;
+            }
+            // 再试 fastjson 标准解析(不加 AllowUnQuotedFieldNames/AllowComment 等容错 Feature,
+            // 这两个 Feature 遇到含 \n 的垃圾响应会错认字段边界,offset 89 的报错的根因就是它俩)
+            try {
+                List<ScheduleStageVO> fast = JSON.parseArray(cand, ScheduleStageVO.class);
+                if (fast != null && !fast.isEmpty()) {
+                    return fast;
+                }
+            } catch (Exception e) {
+                log.debug("AI 排期 fastjson 候选解析失败,尝试下一个:{}", e.getMessage());
+            }
+        }
+        // 兜底:整段直接解析
+        try {
+            List<ScheduleStageVO> list = JSON.parseArray(repaired, ScheduleStageVO.class);
+            if (list != null && !list.isEmpty()) {
+                return list;
+            }
         } catch (Exception e) {
-            return Collections.emptyList();
+            int headLen = Math.min(repaired.length(), 300);
+            log.warn("AI 排期 JSON 解析失败 textLen={} textHead={} err={}",
+                    repaired.length(), repaired.substring(0, headLen), e.getMessage());
         }
+        return Collections.emptyList();
     }
 
-    private static Object firstNonNull(Object... arr) {
-        for (Object o : arr) {
-            if (o != null) return o;
+    /**
+     * Jackson 兜底解析:标准严格,识别控制字符,不会"自作聪明"补引号。
+     * 如果是 Map 形式拿到,再手动 copy 到 ScheduleStageVO。
+     */
+    private List<ScheduleStageVO> parseStagesWithJackson(String cand) {
+        try {
+            com.fasterxml.jackson.databind.ObjectMapper om =
+                    new com.fasterxml.jackson.databind.ObjectMapper();
+            com.fasterxml.jackson.core.type.TypeReference<List<java.util.Map<String, Object>>> tref =
+                    new com.fasterxml.jackson.core.type.TypeReference<List<java.util.Map<String, Object>>>() {};
+            List<java.util.Map<String, Object>> rawList = om.readValue(cand, tref);
+            if (rawList == null || rawList.isEmpty()) {
+                return Collections.emptyList();
+            }
+            List<ScheduleStageVO> result = new ArrayList<>();
+            for (java.util.Map<String, Object> raw : rawList) {
+                if (raw == null) {
+                    continue;
+                }
+                ScheduleStageVO vo = new ScheduleStageVO();
+                Object seq = raw.get("sequence");
+                if (seq == null) {
+                    seq = raw.get("Sequence");
+                }
+                if (seq != null) {
+                    vo.setSequence(seq instanceof Number ? ((Number) seq).intValue()
+                            : Integer.parseInt(seq.toString().trim()));
+                }
+                vo.setStageName(getString(raw, "stage_name", "stageName"));
+                vo.setLocalDatetime(parseDateTime(getString(raw, "local_datetime", "localDatetime")));
+                vo.setBeijingDatetime(parseDateTime(getString(raw, "beijing_datetime", "beijingDatetime")));
+                vo.setWeekday(getString(raw, "weekday", "weekDay"));
+                result.add(vo);
+            }
+            return result;
+        } catch (Exception e) {
+            log.debug("AI 排期 Jackson 候选解析失败:{}", e.getMessage());
+            return null;
+        }
+    }
+
+    private String getString(java.util.Map<String, Object> raw, String k1, String k2) {
+        Object v = raw.get(k1);
+        if (v == null) {
+            v = raw.get(k2);
+        }
+        return v == null ? null : v.toString();
+    }
+
+    private DateTime parseDateTime(String s) {
+        if (s == null || s.isEmpty()) {
+            return null;
+        }
+        try {
+            return new DateTime(s.replace('T', ' ').trim(), "yyyy-MM-dd HH:mm:ss");
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 从文本中抽取所有可能的 JSON 数组候选(按出现顺序)。
+     * 先剥掉思考块,再对所有 [ 做括号配对,得到每个平衡数组。
+     * 最后过滤掉 “明显不是 JSON 数组” 的伪候选(如 [models]、[inputs] 等 Dify 变量名)。
+     */
+    private List<String> extractJsonCandidates(String text) {
+        List<String> result = new ArrayList<>();
+        String cleaned = text.replaceAll("(?s)<think>[\\s\\S]*?</think>", "");
+        int from = 0;
+        while (true) {
+            int start = cleaned.indexOf('[', from);
+            if (start == -1) {
+                break;
+            }
+            String arr = findBalancedArray(cleaned, start);
+            if (arr != null) {
+                // 过滤伪候选:真正的 JSON 数组至少包含 " 或 : 或 { 这三种特征之一。
+                // [models] / [inputs] / [steps] 这种只是 Dify 变量名,不是数组。
+                if (looksLikeJsonArray(arr)) {
+                    result.add(arr);
+                } else if (log.isDebugEnabled()) {
+                    log.debug("AI 排期 过滤伪候选[{}]: head={}",
+                            start, arr.substring(0, Math.min(arr.length(), 80)));
+                }
+                from = start + arr.length();
+            } else {
+                from = start + 1;
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 判读一段 [..] 是否看起来像合法的 JSON 数组(不是 Dify 变量名占位)。
+     * 特征:包含 "(字符串/字段名)、:(键值对)、{(嵌套对象)中的任一。
+     */
+    private boolean looksLikeJsonArray(String arr) {
+        if (arr == null || arr.length() < 3) {
+            return false;
+        }
+        // 空数组 [] 合法
+        String inner = arr.substring(1, arr.length() - 1).trim();
+        if (inner.isEmpty()) {
+            return true;
+        }
+        return inner.indexOf('"') >= 0
+                || inner.indexOf(':') >= 0
+                || inner.indexOf('{') >= 0
+                || inner.indexOf(',') >= 0;
+    }
+
+    /**
+     * 修复模型常见全角/非常规标点与括号,提升 JSON 容错能力。
+     */
+    private String repairJson(String text) {
+        return text.replace('\uFF3B', '[')
+                   .replace('\uFF3D', ']')
+                   .replace('\uFF5B', '{')
+                   .replace('\uFF5D', '}')
+                   .replace('\uFF1A', ':')
+                   .replace('\uFF0C', ',')
+                   .replace('\u201C', '"')
+                   .replace('\u201D', '"')
+                   .replace('\uFF07', '\'');
+    }
+
+    /**
+     * 从 start 位置起,找第一个 [ 配对到它对应的 ](正确处理嵌套、字符串与转义)。
+     * 返回该平衡数组子串;找不到返回 null。
+     */
+    private String findBalancedArray(String text, int start) {
+        if (start < 0 || start >= text.length() || text.charAt(start) != '[') {
+            return null;
+        }
+        int depth = 0;
+        boolean inString = false;
+        for (int i = start; i < text.length(); i++) {
+            char c = text.charAt(i);
+            if (inString) {
+                if (c == '\\') {
+                    i++;
+                    continue;
+                }
+                if (c == '"') {
+                    inString = false;
+                }
+            } else {
+                if (c == '"') {
+                    inString = true;
+                } else if (c == '[') {
+                    depth++;
+                } else if (c == ']') {
+                    depth--;
+                    if (depth == 0) {
+                        return text.substring(start, i + 1);
+                    }
+                }
+            }
         }
         return null;
     }
-}
+}

+ 5 - 4
java/storlead-sasa/storlead-trade/src/main/java/com/storlead/trade/vo/ScheduleStageVO.java

@@ -19,13 +19,14 @@ public class ScheduleStageVO {
     private String stageName;
 
     @ApiModelProperty(value = "当地时间")
-    @JSONField(name = "local_datetime")
-    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(name = "local_datetime", format = "yyyy-MM-dd HH:mm:ss")
+//    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+
     private DateTime localDatetime;
 
     @ApiModelProperty(value = "北京时间")
-    @JSONField(name = "beijing_datetime")
-    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
+    @JSONField(name = "beijing_datetime", format = "yyyy-MM-dd HH:mm:ss")
+//    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
     private DateTime beijingDatetime;
 
     @ApiModelProperty(value = "星期几")

+ 321 - 242
java/storlead-sasa/storlead-trade/src/main/resources/mapper/CustomerEntityMapper.xml

@@ -2,134 +2,215 @@
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.storlead.trade.mapper.CustomerEntityMapper">
 
-    <!-- CustomerVO 自动映射:继承 CustomerEntity 全部字段 + customerCompany + liaison -->
+    <!--
+        MyBatis association 嵌套映射:
+        customerCompany → prefix "cc_"
+        liaison         → prefix "l_"
+        MyBatis 会自动处理 snake_case → camelCase 映射 + association 嵌套。
+        无需 Java 层手动转换。
+    -->
 
-    <!-- 联表分页查询:客户 + 企业信息 + 首要联系人 -->
-    <select id="selectPageWithRelation" resultType="com.storlead.trade.vo.CustomerVO">
+    <!-- ====== 结果映射:客户 + 嵌套企业信息 + 嵌套联系人 ====== -->
+    <resultMap id="CustomerVOResultMap" type="com.storlead.trade.vo.CustomerVO" autoMapping="true">
+        <!-- 客户主表字段由 MyBatis autoMapping 自动映射(snake→camel) -->
+        <!-- 嵌套:企业信息 -->
+        <association property="customerCompany" javaType="com.storlead.trade.entity.CustomerCompanyEntity" autoMapping="true">
+            <id column="cc_id" property="id"/>
+            <result column="cc_customer_id" property="customerId"/>
+            <result column="cc_name" property="name"/>
+            <result column="cc_address" property="address"/>
+            <result column="cc_scale_dict_value" property="scaleDictValue"/>
+            <result column="cc_website" property="website"/>
+            <result column="cc_website1" property="website1"/>
+            <result column="cc_website2" property="website2"/>
+            <result column="cc_website3" property="website3"/>
+            <result column="cc_industry" property="industry"/>
+            <result column="cc_communication_address" property="communicationAddress"/>
+            <result column="cc_postcode" property="postcode"/>
+            <result column="cc_fax_number" property="faxNumber"/>
+            <result column="cc_email" property="email"/>
+            <result column="cc_email1" property="email1"/>
+            <result column="cc_email2" property="email2"/>
+            <result column="cc_email3" property="email3"/>
+            <result column="cc_telephone" property="telephone"/>
+            <result column="cc_create_time" property="createTime"/>
+            <result column="cc_update_time" property="updateTime"/>
+            <result column="cc_is_delete" property="isDelete"/>
+            <result column="cc_enabled" property="enabled"/>
+            <result column="cc_create_by" property="createBy"/>
+            <result column="cc_owner_by" property="ownerBy"/>
+            <result column="cc_update_by" property="updateBy"/>
+            <result column="cc_sort" property="sort"/>
+        </association>
+        <!-- 嵌套:首要联系人 -->
+        <association property="liaison" javaType="com.storlead.trade.entity.LiaisonEntity" autoMapping="true">
+            <id column="l_id" property="id"/>
+            <result column="l_customer_id" property="customerId"/>
+            <result column="l_has_primary_lialison" property="hasPrimaryLialison"/>
+            <result column="l_name" property="name"/>
+            <result column="l_call" property="call"/>
+            <result column="l_resource_type" property="resourceType"/>
+            <result column="l_resource_type_id" property="resourceTypeId"/>
+            <result column="l_liaison_role_dict_value" property="liaisonRoleDictValue"/>
+            <result column="l_birthday" property="birthday"/>
+            <result column="l_department_position" property="departmentPosition"/>
+            <result column="l_telephone" property="telephone"/>
+            <result column="l_email" property="email"/>
+            <result column="l_email1" property="email1"/>
+            <result column="l_email2" property="email2"/>
+            <result column="l_email3" property="email3"/>
+            <result column="l_fax_number" property="faxNumber"/>
+            <result column="l_postcode" property="postcode"/>
+            <result column="l_contact_information" property="contactInformation"/>
+            <result column="l_remark" property="remark"/>
+            <result column="l_qq" property="qq"/>
+            <result column="l_skype" property="skype"/>
+            <result column="l_wangwang" property="wangwang"/>
+            <result column="l_wechat" property="wechat"/>
+            <result column="l_twitter" property="twitter"/>
+            <result column="l_linkedin" property="linkedin"/>
+            <result column="l_facebook" property="facebook"/>
+            <result column="l_whatsapp" property="whatsapp"/>
+            <result column="l_fixed_line_telephone" property="fixedLineTelephone"/>
+            <result column="l_home_electricity" property="homeElectricity"/>
+            <result column="l_address" property="address"/>
+            <result column="l_international_area" property="internationalArea"/>
+            <result column="l_last_follow_up_time" property="lastFollowUpTime"/>
+            <result column="l_create_time" property="createTime"/>
+            <result column="l_update_time" property="updateTime"/>
+            <result column="l_is_delete" property="isDelete"/>
+            <result column="l_enabled" property="enabled"/>
+            <result column="l_create_by" property="createBy"/>
+            <result column="l_owner_by" property="ownerBy"/>
+            <result column="l_former_owner_by" property="formerOwnerBy"/>
+            <result column="l_update_by" property="updateBy"/>
+            <result column="l_sort" property="sort"/>
+        </association>
+    </resultMap>
+
+    <!-- 联表分页查询 -->
+    <select id="selectPageWithRelation" resultMap="CustomerVOResultMap">
         SELECT
-            c.id               AS id,
-            c.data_code        AS dataCode,
-            c.customer_form    AS customerForm,
-            c.has_from_clue   AS hasFromClue,
-            c.has_public_customer   AS hasPublicCustomer,
-            c.has_garbage_customer  AS hasGarbageCustomer,
-            c.has_follow_customer   AS hasFollowCustomer,
-            c.customer_code    AS customerCode,
-            c.continent        AS continent,
-            c.country          AS country,
-            c.customer_name    AS customerName,
-            c.collaborator     AS collaborator,
-            c.intended_products_ids    AS intendedProductsIds,
-            c.intended_products_names   AS intendedProductsNames,
-            c.clue_status_dict_value     AS clueStatusDictValue,
-            c.customer_status_dict_value AS customerStatusDictValue,
-            c.customer_source_dict_value AS customerSourceDictValue,
-            c.customer_type_dict_value   AS customerTypeDictValue,
-            c.cooperative_customer_id    AS cooperativeCustomerId,
-            c.customer_level_dict_value  AS customerLevelDictValue,
-            c.customer_credit_level_dict_value  AS customerCreditLevelDictValue,
-            c.customer_economic_nature_dict_value AS customerEconomicNatureDictValue,
-            c.mnemonic_name    AS mnemonicName,
-            c.remark          AS remark,
-            c.opening_bank    AS openingBank,
-            c.tax_number      AS taxNumber,
-            c.bank_account    AS bankAccount,
-            c.reason_public_customer_dict_value AS reasonPublicCustomerDictValue,
-            c.has_order_form  AS hasOrderForm,
-            c.last_follow_up_time AS lastFollowUpTime,
-            c.no_follow_up_date   AS noFollowUpDate,
-            c.public_date     AS publicDate,
-            c.public_remind_date  AS publicRemindDate,
-            c.garbage_date    AS garbageDate,
-            c.garbage_remind_date AS garbageRemindDate,
-            c.delete_date     AS deleteDate,
-            c.delete_remind_date  AS deleteRemindDate,
-            c.owner_time      AS ownerTime,
-            c.give_up_time    AS giveUpTime,
-            c.create_time     AS createTime,
-            c.update_time     AS updateTime,
-            c.is_delete       AS isDelete,
-            c.enabled         AS enabled,
-            c.create_by       AS createBy,
-            c.owner_by        AS ownerBy,
-            c.former_owner_by AS formerOwnerBy,
-            c.next_analysis_date AS nextAnalysisDate,
-            c.update_by       AS updateBy,
-            c.sort            AS sort,
-            <!-- 企业信息 -->
-            cc.id             AS customerCompany.id,
-            cc.customer_id    AS customerCompany.customerId,
-            cc.name           AS customerCompany.name,
-            cc.address        AS customerCompany.address,
-            cc.scale_dict_value AS customerCompany.scaleDictValue,
-            cc.website        AS customerCompany.website,
-            cc.website1       AS customerCompany.website1,
-            cc.website2       AS customerCompany.website2,
-            cc.website3       AS customerCompany.website3,
-            cc.industry       AS customerCompany.industry,
-            cc.communication_address AS customerCompany.communicationAddress,
-            cc.postcode       AS customerCompany.postcode,
-            cc.fax_number     AS customerCompany.faxNumber,
-            cc.email          AS customerCompany.email,
-            cc.email1         AS customerCompany.email1,
-            cc.email2         AS customerCompany.email2,
-            cc.email3         AS customerCompany.email3,
-            cc.telephone      AS customerCompany.telephone,
-            cc.create_time    AS customerCompany.createTime,
-            cc.update_time    AS customerCompany.updateTime,
-            cc.is_delete      AS customerCompany.isDelete,
-            cc.enabled        AS customerCompany.enabled,
-            cc.create_by      AS customerCompany.createBy,
-            cc.owner_by       AS customerCompany.ownerBy,
-            cc.update_by      AS customerCompany.updateBy,
-            cc.sort           AS customerCompany.sort,
-            <!-- 首要联系人 -->
-            l.id              AS liaison.id,
-            l.customer_id     AS liaison.customerId,
-            l.has_primary_lialison   AS liaison.hasPrimaryLialison,
-            l.name            AS liaison.name,
-            l.call            AS liaison.call,
-            l.resource_type  AS liaison.resourceType,
-            l.resource_type_id AS liaison.resourceTypeId,
-            l.liaison_role_dict_value AS liaison.liaisonRoleDictValue,
-            l.birthday        AS liaison.birthday,
-            l.department_position AS liaison.departmentPosition,
-            l.telephone       AS liaison.telephone,
-            l.email           AS liaison.email,
-            l.email1          AS liaison.email1,
-            l.email2          AS liaison.email2,
-            l.email3          AS liaison.email3,
-            l.fax_number      AS liaison.faxNumber,
-            l.postcode        AS liaison.postcode,
-            l.contact_information AS liaison.contactInformation,
-            l.remark          AS liaison.remark,
-            l.qq              AS liaison.qq,
-            l.skype           AS liaison.skype,
-            l.wangwang        AS liaison.wangwang,
-            l.wechat          AS liaison.wechat,
-            l.twitter         AS liaison.twitter,
-            l.linkedin        AS liaison.linkedin,
-            l.facebook        AS liaison.facebook,
-            l.whatsapp        AS liaison.whatsapp,
-            l.fixed_line_telephone AS liaison.fixedLineTelephone,
-            l.home_electricity AS liaison.homeElectricity,
-            l.address         AS liaison.address,
-            l.international_area  AS liaison.internationalArea,
-            l.last_follow_up_time  AS liaison.lastFollowUpTime,
-            l.create_time     AS liaison.createTime,
-            l.update_time     AS liaison.updateTime,
-            l.is_delete       AS liaison.isDelete,
-            l.enabled         AS liaison.enabled,
-            l.create_by       AS liaison.createBy,
-            l.owner_by        AS liaison.ownerBy,
-            l.former_owner_by AS liaison.formerOwnerBy,
-            l.update_by       AS liaison.updateBy,
-            l.sort            AS liaison.sort
+            c.id,
+            c.data_code,
+            c.customer_form,
+            c.has_from_clue,
+            c.has_public_customer,
+            c.has_garbage_customer,
+            c.has_follow_customer,
+            c.customer_code,
+            c.continent,
+            c.country,
+            c.customer_name,
+            c.collaborator,
+            c.intended_products_ids,
+            c.intended_products_names,
+            c.clue_status_dict_value,
+            c.customer_status_dict_value,
+            c.customer_source_dict_value,
+            c.customer_type_dict_value,
+            c.cooperative_customer_id,
+            c.customer_level_dict_value,
+            c.customer_credit_level_dict_value,
+            c.customer_economic_nature_dict_value,
+            c.mnemonic_name,
+            c.remark,
+            c.opening_bank,
+            c.tax_number,
+            c.bank_account,
+            c.reason_public_customer_dict_value,
+            c.has_order_form,
+            c.last_follow_up_time,
+            c.no_follow_up_date,
+            c.public_date,
+            c.public_remind_date,
+            c.garbage_date,
+            c.garbage_remind_date,
+            c.delete_date,
+            c.delete_remind_date,
+            c.owner_time,
+            c.give_up_time,
+            c.create_time,
+            c.update_time,
+            c.is_delete,
+            c.enabled,
+            c.create_by,
+            c.owner_by,
+            c.former_owner_by,
+            c.next_analysis_date,
+            c.update_by,
+            c.sort,
+            cc.id              AS cc_id,
+            cc.customer_id     AS cc_customer_id,
+            cc.name            AS cc_name,
+            cc.address         AS cc_address,
+            cc.scale_dict_value  AS cc_scale_dict_value,
+            cc.website         AS cc_website,
+            cc.website1        AS cc_website1,
+            cc.website2        AS cc_website2,
+            cc.website3        AS cc_website3,
+            cc.industry        AS cc_industry,
+            cc.communication_address AS cc_communication_address,
+            cc.postcode        AS cc_postcode,
+            cc.fax_number      AS cc_fax_number,
+            cc.email           AS cc_email,
+            cc.email1          AS cc_email1,
+            cc.email2          AS cc_email2,
+            cc.email3          AS cc_email3,
+            cc.telephone       AS cc_telephone,
+            cc.create_time     AS cc_create_time,
+            cc.update_time     AS cc_update_time,
+            cc.is_delete       AS cc_is_delete,
+            cc.enabled         AS cc_enabled,
+            cc.create_by       AS cc_create_by,
+            cc.owner_by        AS cc_owner_by,
+            cc.update_by       AS cc_update_by,
+            cc.sort            AS cc_sort,
+            l.id               AS l_id,
+            l.customer_id      AS l_customer_id,
+            l.has_primary_lialison  AS l_has_primary_lialison,
+            l.name             AS l_name,
+            l.call             AS l_call,
+            l.resource_type    AS l_resource_type,
+            l.resource_type_id AS l_resource_type_id,
+            l.liaison_role_dict_value  AS l_liaison_role_dict_value,
+            l.birthday         AS l_birthday,
+            l.department_position  AS l_department_position,
+            l.telephone        AS l_telephone,
+            l.email            AS l_email,
+            l.email1           AS l_email1,
+            l.email2           AS l_email2,
+            l.email3           AS l_email3,
+            l.fax_number       AS l_fax_number,
+            l.postcode         AS l_postcode,
+            l.contact_information  AS l_contact_information,
+            l.remark           AS l_remark,
+            l.qq               AS l_qq,
+            l.skype            AS l_skype,
+            l.wangwang         AS l_wangwang,
+            l.wechat           AS l_wechat,
+            l.twitter          AS l_twitter,
+            l.linkedin         AS l_linkedin,
+            l.facebook         AS l_facebook,
+            l.whatsapp         AS l_whatsapp,
+            l.fixed_line_telephone  AS l_fixed_line_telephone,
+            l.home_electricity AS l_home_electricity,
+            l.address          AS l_address,
+            l.international_area   AS l_international_area,
+            l.last_follow_up_time  AS l_last_follow_up_time,
+            l.create_time      AS l_create_time,
+            l.update_time      AS l_update_time,
+            l.is_delete        AS l_is_delete,
+            l.enabled          AS l_enabled,
+            l.create_by        AS l_create_by,
+            l.owner_by         AS l_owner_by,
+            l.former_owner_by  AS l_former_owner_by,
+            l.update_by        AS l_update_by,
+            l.sort             AS l_sort
         FROM customer c
         LEFT JOIN customer_company cc ON cc.customer_id = c.id AND cc.is_delete = 0
         LEFT JOIN liaison l ON l.customer_id = c.id AND l.is_delete = 0 AND l.has_primary_lialison = 1
         WHERE c.is_delete = 0
-        <!-- customer 表筛选 -->
         <if test="dto.id != null">
             AND c.id = #{dto.id}
         </if>
@@ -178,11 +259,9 @@
         <if test="dto.hasOrderForm != null">
             AND c.has_order_form = #{dto.hasOrderForm}
         </if>
-        <!-- 联表筛选:customer_company -->
         <if test="dto.companyName != null and dto.companyName != ''">
             AND cc.name LIKE CONCAT('%', #{dto.companyName}, '%')
         </if>
-        <!-- 联表筛选:liaison -->
         <if test="dto.liaisonName != null and dto.liaisonName != ''">
             AND l.name LIKE CONCAT('%', #{dto.liaisonName}, '%')
         </if>
@@ -195,125 +274,125 @@
         ORDER BY c.create_time DESC
     </select>
 
-    <!-- 根据客户ID查询详情(含企业信息+首要联系人) -->
-    <select id="selectDetailByCustomerId" resultType="com.storlead.trade.vo.CustomerVO">
+    <!-- 详情查询 -->
+    <select id="selectDetailByCustomerId" resultMap="CustomerVOResultMap">
         SELECT
-            c.id               AS id,
-            c.data_code        AS dataCode,
-            c.customer_form   AS customerForm,
-            c.has_from_clue    AS hasFromClue,
-            c.has_public_customer  AS hasPublicCustomer,
-            c.has_garbage_customer AS hasGarbageCustomer,
-            c.has_follow_customer  AS hasFollowCustomer,
-            c.customer_code    AS customerCode,
-            c.continent        AS continent,
-            c.country          AS country,
-            c.customer_name    AS customerName,
-            c.collaborator     AS collaborator,
-            c.intended_products_ids   AS intendedProductsIds,
-            c.intended_products_names  AS intendedProductsNames,
-            c.clue_status_dict_value    AS clueStatusDictValue,
-            c.customer_status_dict_value  AS customerStatusDictValue,
-            c.customer_source_dict_value  AS customerSourceDictValue,
-            c.customer_type_dict_value    AS customerTypeDictValue,
-            c.cooperative_customer_id     AS cooperativeCustomerId,
-            c.customer_level_dict_value   AS customerLevelDictValue,
-            c.customer_credit_level_dict_value AS customerCreditLevelDictValue,
-            c.customer_economic_nature_dict_value AS customerEconomicNatureDictValue,
-            c.mnemonic_name     AS mnemonicName,
-            c.remark           AS remark,
-            c.opening_bank     AS openingBank,
-            c.tax_number       AS taxNumber,
-            c.bank_account     AS bankAccount,
-            c.reason_public_customer_dict_value AS reasonPublicCustomerDictValue,
-            c.has_order_form   AS hasOrderForm,
-            c.last_follow_up_time  AS lastFollowUpTime,
-            c.no_follow_up_date   AS noFollowUpDate,
-            c.public_date      AS publicDate,
-            c.public_remind_date  AS publicRemindDate,
-            c.garbage_date     AS garbageDate,
-            c.garbage_remind_date AS garbageRemindDate,
-            c.delete_date      AS deleteDate,
-            c.delete_remind_date AS deleteRemindDate,
-            c.owner_time       AS ownerTime,
-            c.give_up_time     AS giveUpTime,
-            c.create_time      AS createTime,
-            c.update_time      AS updateTime,
-            c.is_delete        AS isDelete,
-            c.enabled          AS enabled,
-            c.create_by        AS createBy,
-            c.owner_by         AS ownerBy,
-            c.former_owner_by  AS formerOwnerBy,
-            c.next_analysis_date AS nextAnalysisDate,
-            c.update_by        AS updateBy,
-            c.sort             AS sort,
-            cc.id              AS customerCompany.id,
-            cc.customer_id     AS customerCompany.customerId,
-            cc.name            AS customerCompany.name,
-            cc.address         AS customerCompany.address,
-            cc.scale_dict_value  AS customerCompany.scaleDictValue,
-            cc.website         AS customerCompany.website,
-            cc.website1        AS customerCompany.website1,
-            cc.website2        AS customerCompany.website2,
-            cc.website3        AS customerCompany.website3,
-            cc.industry        AS customerCompany.industry,
-            cc.communication_address AS customerCompany.communicationAddress,
-            cc.postcode        AS customerCompany.postcode,
-            cc.fax_number      AS customerCompany.faxNumber,
-            cc.email           AS customerCompany.email,
-            cc.email1          AS customerCompany.email1,
-            cc.email2          AS customerCompany.email2,
-            cc.email3          AS customerCompany.email3,
-            cc.telephone       AS customerCompany.telephone,
-            cc.create_time     AS customerCompany.createTime,
-            cc.update_time      AS customerCompany.updateTime,
-            cc.is_delete        AS customerCompany.isDelete,
-            cc.enabled          AS customerCompany.enabled,
-            cc.create_by        AS customerCompany.createBy,
-            cc.owner_by         AS customerCompany.ownerBy,
-            cc.update_by        AS customerCompany.updateBy,
-            cc.sort             AS customerCompany.sort,
-            l.id               AS liaison.id,
-            l.customer_id      AS liaison.customerId,
-            l.has_primary_lialison    AS liaison.hasPrimaryLialison,
-            l.name             AS liaison.name,
-            l.call             AS liaison.call,
-            l.resource_type    AS liaison.resourceType,
-            l.resource_type_id AS liaison.resourceTypeId,
-            l.liaison_role_dict_value  AS liaison.liaisonRoleDictValue,
-            l.birthday         AS liaison.birthday,
-            l.department_position  AS liaison.departmentPosition,
-            l.telephone        AS liaison.telephone,
-            l.email            AS liaison.email,
-            l.email1           AS liaison.email1,
-            l.email2           AS liaison.email2,
-            l.email3           AS liaison.email3,
-            l.fax_number       AS liaison.faxNumber,
-            l.postcode         AS liaison.postcode,
-            l.contact_information  AS liaison.contactInformation,
-            l.remark           AS liaison.remark,
-            l.qq               AS liaison.qq,
-            l.skype            AS liaison.skype,
-            l.wangwang         AS liaison.wangwang,
-            l.wechat           AS liaison.wechat,
-            l.twitter          AS liaison.twitter,
-            l.linkedin         AS liaison.linkedin,
-            l.facebook         AS liaison.facebook,
-            l.whatsapp         AS liaison.whatsapp,
-            l.fixed_line_telephone  AS liaison.fixedLineTelephone,
-            l.home_electricity AS liaison.homeElectricity,
-            l.address          AS liaison.address,
-            l.international_area    AS liaison.internationalArea,
-            l.last_follow_up_time   AS liaison.lastFollowUpTime,
-            l.create_time      AS liaison.createTime,
-            l.update_time      AS liaison.updateTime,
-            l.is_delete        AS liaison.isDelete,
-            l.enabled          AS liaison.enabled,
-            l.create_by        AS liaison.createBy,
-            l.owner_by         AS liaison.ownerBy,
-            l.former_owner_by  AS liaison.formerOwnerBy,
-            l.update_by        AS liaison.updateBy,
-            l.sort             AS liaison.sort
+            c.id,
+            c.data_code,
+            c.customer_form,
+            c.has_from_clue,
+            c.has_public_customer,
+            c.has_garbage_customer,
+            c.has_follow_customer,
+            c.customer_code,
+            c.continent,
+            c.country,
+            c.customer_name,
+            c.collaborator,
+            c.intended_products_ids,
+            c.intended_products_names,
+            c.clue_status_dict_value,
+            c.customer_status_dict_value,
+            c.customer_source_dict_value,
+            c.customer_type_dict_value,
+            c.cooperative_customer_id,
+            c.customer_level_dict_value,
+            c.customer_credit_level_dict_value,
+            c.customer_economic_nature_dict_value,
+            c.mnemonic_name,
+            c.remark,
+            c.opening_bank,
+            c.tax_number,
+            c.bank_account,
+            c.reason_public_customer_dict_value,
+            c.has_order_form,
+            c.last_follow_up_time,
+            c.no_follow_up_date,
+            c.public_date,
+            c.public_remind_date,
+            c.garbage_date,
+            c.garbage_remind_date,
+            c.delete_date,
+            c.delete_remind_date,
+            c.owner_time,
+            c.give_up_time,
+            c.create_time,
+            c.update_time,
+            c.is_delete,
+            c.enabled,
+            c.create_by,
+            c.owner_by,
+            c.former_owner_by,
+            c.next_analysis_date,
+            c.update_by,
+            c.sort,
+            cc.id              AS cc_id,
+            cc.customer_id     AS cc_customer_id,
+            cc.name            AS cc_name,
+            cc.address         AS cc_address,
+            cc.scale_dict_value  AS cc_scale_dict_value,
+            cc.website         AS cc_website,
+            cc.website1        AS cc_website1,
+            cc.website2        AS cc_website2,
+            cc.website3        AS cc_website3,
+            cc.industry        AS cc_industry,
+            cc.communication_address AS cc_communication_address,
+            cc.postcode        AS cc_postcode,
+            cc.fax_number      AS cc_fax_number,
+            cc.email           AS cc_email,
+            cc.email1          AS cc_email1,
+            cc.email2          AS cc_email2,
+            cc.email3          AS cc_email3,
+            cc.telephone       AS cc_telephone,
+            cc.create_time     AS cc_create_time,
+            cc.update_time     AS cc_update_time,
+            cc.is_delete       AS cc_is_delete,
+            cc.enabled         AS cc_enabled,
+            cc.create_by       AS cc_create_by,
+            cc.owner_by        AS cc_owner_by,
+            cc.update_by       AS cc_update_by,
+            cc.sort            AS cc_sort,
+            l.id               AS l_id,
+            l.customer_id      AS l_customer_id,
+            l.has_primary_lialison  AS l_has_primary_lialison,
+            l.name             AS l_name,
+            l.call             AS l_call,
+            l.resource_type    AS l_resource_type,
+            l.resource_type_id AS l_resource_type_id,
+            l.liaison_role_dict_value  AS l_liaison_role_dict_value,
+            l.birthday         AS l_birthday,
+            l.department_position  AS l_department_position,
+            l.telephone        AS l_telephone,
+            l.email            AS l_email,
+            l.email1           AS l_email1,
+            l.email2           AS l_email2,
+            l.email3           AS l_email3,
+            l.fax_number       AS l_fax_number,
+            l.postcode         AS l_postcode,
+            l.contact_information  AS l_contact_information,
+            l.remark           AS l_remark,
+            l.qq               AS l_qq,
+            l.skype            AS l_skype,
+            l.wangwang         AS l_wangwang,
+            l.wechat           AS l_wechat,
+            l.twitter          AS l_twitter,
+            l.linkedin         AS l_linkedin,
+            l.facebook         AS l_facebook,
+            l.whatsapp         AS l_whatsapp,
+            l.fixed_line_telephone  AS l_fixed_line_telephone,
+            l.home_electricity AS l_home_electricity,
+            l.address          AS l_address,
+            l.international_area   AS l_international_area,
+            l.last_follow_up_time  AS l_last_follow_up_time,
+            l.create_time      AS l_create_time,
+            l.update_time      AS l_update_time,
+            l.is_delete        AS l_is_delete,
+            l.enabled          AS l_enabled,
+            l.create_by        AS l_create_by,
+            l.owner_by         AS l_owner_by,
+            l.former_owner_by  AS l_former_owner_by,
+            l.update_by        AS l_update_by,
+            l.sort             AS l_sort
         FROM customer c
         LEFT JOIN customer_company cc ON cc.customer_id = c.id AND cc.is_delete = 0
         LEFT JOIN liaison l ON l.customer_id = c.id AND l.is_delete = 0 AND l.has_primary_lialison = 1