feat: 完成CRM-ERP客户同步管理模块

主要功能:
- 新增同步管理后台界面,支持全局管理(不限租户)
- 实现租户-ERP公司映射配置,支持多公司绑定
- 实现定时同步任务,可配置Cron表达式
- 实现手动同步功能,从ERP拉取客户数据更新CRM
- 支持新增经销商(ERP客户在CRM不存在时)
- 新增同步日志和预警管理

技术实现:
- 新增 crm_sync_log、crm_sync_alert、sys_tenant_company 表
- 使用 TenantHelper.ignore() 实现全局查询(忽略租户过滤)
- ERP动态API支持 ${param} 字符串替换用于IN条件
- 新增公司列表动态API配置

同步流程:
1. 获取租户-公司映射配置
2. 从ERP获取各公司客户列表
3. 匹配CRM已有经销商并更新
4. 未匹配且ERP未停用的客户新增为经销商
5. 生成差异预警(CRM维护字段)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
大壮
2026-05-22 09:40:35 +00:00
parent d42ad5e1e1
commit 5cb9e367df
27 changed files with 2923 additions and 14 deletions

View File

@@ -79,4 +79,17 @@ public class CustomerController extends BaseController {
public R<List<CustomerVO>> brands() {
return R.ok(customerService.getBrands());
}
/**
* 客户选择列表用于CRM选择器
* 支持按公司过滤companyIds逗号分隔
*/
@GetMapping("/select")
public TableDataInfo<CustomerVO> select(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String companyIds) {
return customerService.queryCustomerSelectList(pageNum, pageSize, keyword, companyIds);
}
}

View File

@@ -143,4 +143,60 @@ public interface CustomerMapper extends BaseMapper<CustomerGeneral> {
"WHERE BRAND IS NOT NULL AND BRANDNAME IS NOT NULL " +
"ORDER BY BRAND")
List<CustomerVO> selectBrands();
/**
* 客户选择列表用于CRM选择器支持多公司过滤
*/
@Select("<script>" +
"SELECT TOP ${pageSize} * FROM (" +
" SELECT ROW_NUMBER() OVER (ORDER BY CLTCODE) AS rn, " +
" CLTCODE AS customerCode, " +
" CLTNAME AS customerName, " +
" COMPANY_ID AS companyCode, " +
" COMPANY_NAME AS companyName, " +
" LINKMAN AS contactName, " +
" TEL1 AS phone, " +
" province, city, ISSTOP AS isStop " +
" FROM SCLTGENERAL " +
" WHERE 1=1 " +
" <if test='keyword != null and keyword != \"\"'>" +
" AND (CLTCODE LIKE '%' + #{keyword} + '%' " +
" OR CLTNAME LIKE '%' + #{keyword} + '%' " +
" OR LINKMAN LIKE '%' + #{keyword} + '%') " +
" </if>" +
" <if test='companyIds != null and companyIds != \"\"'>" +
" AND COMPANY_ID IN " +
" <foreach collection='companyIdList' item='id' open='(' separator=',' close=')'>" +
" #{id}" +
" </foreach>" +
" </if>" +
") t WHERE rn &gt; (${pageNum} - 1) * ${pageSize} ORDER BY rn" +
"</script>")
List<CustomerVO> selectCustomerSelectPage(@Param("pageNum") int pageNum,
@Param("pageSize") int pageSize,
@Param("keyword") String keyword,
@Param("companyIds") String companyIds,
@Param("companyIdList") List<String> companyIdList);
/**
* 客户选择总数用于CRM选择器支持多公司过滤
*/
@Select("<script>" +
"SELECT COUNT(*) FROM SCLTGENERAL " +
"WHERE 1=1 " +
"<if test='keyword != null and keyword != \"\"'>" +
" AND (CLTCODE LIKE '%' + #{keyword} + '%' " +
" OR CLTNAME LIKE '%' + #{keyword} + '%' " +
" OR LINKMAN LIKE '%' + #{keyword} + '%') " +
"</if>" +
"<if test='companyIds != null and companyIds != \"\"'>" +
" AND COMPANY_ID IN " +
" <foreach collection='companyIdList' item='id' open='(' separator=',' close=')'>" +
" #{id}" +
" </foreach>" +
"</if>" +
"</script>")
long selectCustomerSelectCount(@Param("keyword") String keyword,
@Param("companyIds") String companyIds,
@Param("companyIdList") List<String> companyIdList);
}

View File

@@ -30,4 +30,9 @@ public interface ICustomerService {
* 获取所有品牌列表
*/
List<CustomerVO> getBrands();
/**
* 客户选择列表用于CRM选择器支持多公司过滤
*/
TableDataInfo<CustomerVO> queryCustomerSelectList(int pageNum, int pageSize, String keyword, String companyIds);
}

View File

@@ -8,6 +8,8 @@ import org.hzhub.erp.mapper.SalesOrganizationMapper;
import org.hzhub.erp.service.ICustomerService;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
@@ -43,4 +45,17 @@ public class CustomerServiceImpl implements ICustomerService {
public List<CustomerVO> getBrands() {
return customerMapper.selectBrands();
}
@Override
public TableDataInfo<CustomerVO> queryCustomerSelectList(int pageNum, int pageSize, String keyword, String companyIds) {
// 解析companyIds为列表
List<String> companyIdList = Collections.emptyList();
if (companyIds != null && !companyIds.isBlank()) {
companyIdList = Arrays.asList(companyIds.split(","));
}
long total = customerMapper.selectCustomerSelectCount(keyword, companyIds, companyIdList);
List<CustomerVO> list = customerMapper.selectCustomerSelectPage(pageNum, pageSize, keyword, companyIds, companyIdList);
return new TableDataInfo<>(list, total);
}
}

View File

@@ -127,16 +127,47 @@ public class DynamicApiExecutor {
}
/**
* 处理SQL模板将 #{param} 转换为 ? 占位符,并提取参数值)
* 处理SQL模板将 #{param} 转换为 ? 占位符,${param} 直接替换为参数值)
*
* @param sqlTemplate SQL模板
* @param params 参数Map
* @return 处理后的SQL和参数值列表
*/
private ProcessedSql processSqlTemplate(String sqlTemplate, Map<String, Object> params) {
// 正则表达式匹配 #{paramName}
log.info("处理SQL模板: params={}", params);
// 1. 先处理 ${param} 字符串替换用于IN条件等
Pattern stringPattern = Pattern.compile("\\$\\{(\\w+)\\}");
Matcher stringMatcher = stringPattern.matcher(sqlTemplate);
StringBuffer intermediateSql = new StringBuffer();
while (stringMatcher.find()) {
String paramName = stringMatcher.group(1);
Object paramValue = params.get(paramName);
log.info("处理${}参数: paramName={}, paramValue={}", paramName, paramValue);
if (paramValue != null) {
// 直接替换参数值(字符串拼接)
// 安全检查:只允许包含数字、字母、逗号、单引号的值
String valueStr = paramValue.toString();
if (!isValidStringValue(valueStr)) {
throw new SecurityException("参数值包含非法字符: " + paramName);
}
stringMatcher.appendReplacement(intermediateSql, valueStr);
} else {
// 参数为空时替换为空字符串可能导致SQL语法问题需注意
log.warn("参数{}为空,替换为空字符串", paramName);
stringMatcher.appendReplacement(intermediateSql, "");
}
}
stringMatcher.appendTail(intermediateSql);
log.info("字符串替换后SQL: {}", intermediateSql);
// 2. 再处理 #{param} 预编译参数
Pattern pattern = Pattern.compile("#\\{(\\w+)\\}");
Matcher matcher = pattern.matcher(sqlTemplate);
Matcher matcher = pattern.matcher(intermediateSql.toString());
List<Object> paramValues = new ArrayList<>();
StringBuffer processedSql = new StringBuffer();
@@ -160,6 +191,14 @@ public class DynamicApiExecutor {
return new ProcessedSql(finalSql, paramValues);
}
/**
* 验证字符串值是否安全(只允许数字、字母、逗号、单引号)
*/
private boolean isValidStringValue(String value) {
// 只允许:数字、字母、逗号、单引号、空格
return value.matches("^[0-9a-zA-Z,\\'\\s]+$");
}
/**
* 处理 WHERE 条件中的动态逻辑IS NOT NULL THEN ...
* 例如WHERE #{customerCode} IS NOT NULL THEN customer_code = #{customerCode}