diff --git a/hzhub-admin/apps/web-antd/src/api/crm/sync.ts b/hzhub-admin/apps/web-antd/src/api/crm/sync.ts new file mode 100644 index 0000000..5bb02e3 --- /dev/null +++ b/hzhub-admin/apps/web-antd/src/api/crm/sync.ts @@ -0,0 +1,116 @@ +import { requestClient } from '#/api/request'; + +enum Api { + config = '/crm/sync/config', + start = '/crm/sync/start', + stop = '/crm/sync/stop', + cron = '/crm/sync/cron', + execute = '/crm/sync/execute', + logs = '/crm/sync/logs', + alerts = '/crm/sync/alerts', + stats = '/crm/sync/stats', + tenants = '/crm/sync/tenants', + tenantCompanies = '/crm/sync/tenant-companies', + erpCompanies = '/crm/sync/erp-companies', + bindCompany = '/crm/sync/tenant-companies/bind', + unbindCompany = '/crm/sync/tenant-companies/unbind', + resolveAlert = '/crm/sync/alerts', +} + +/** + * 获取同步配置 + */ +export function getSyncConfig() { + return requestClient.get<{ enabled: boolean; cron: string }>(Api.config); +} + +/** + * 启动定时任务 + */ +export function startSyncTask() { + return requestClient.post(Api.start, {}); +} + +/** + * 停止定时任务 + */ +export function stopSyncTask() { + return requestClient.post(Api.stop, {}); +} + +/** + * 设置Cron表达式 + */ +export function setSyncCron(cron: string) { + return requestClient.put(Api.cron, null, { params: { cron } }); +} + +/** + * 手动触发同步 + */ +export function executeSync() { + return requestClient.post(Api.execute, {}); +} + +/** + * 查询同步日志 + */ +export function getSyncLogs(params: { pageNum: number; pageSize: number }) { + return requestClient.get<{ rows: any[]; total: number }>(Api.logs, { params }); +} + +/** + * 查询预警列表 + */ +export function getSyncAlerts(params: { pageNum: number; pageSize: number; status?: string }) { + return requestClient.get<{ rows: any[]; total: number }>(Api.alerts, { params }); +} + +/** + * 处理预警 + */ +export function resolveAlert(alertId: number, action: string, note?: string) { + return requestClient.put(`${Api.resolveAlert}/${alertId}`, null, { params: { action, note } }); +} + +/** + * 获取同步统计数据 + */ +export function getSyncStats() { + return requestClient.get(Api.stats); +} + +/** + * 获取租户列表(用于选择器) + */ +export function getTenants() { + return requestClient.get(Api.tenants); +} + +/** + * 获取所有租户的ERP公司映射列表 + */ +export function getTenantCompanies() { + return requestClient.get(Api.tenantCompanies); +} + +/** + * 获取ERP所有公司列表 + */ +export function getErpCompanies() { + return requestClient.get(Api.erpCompanies); +} + +/** + * 绑定ERP公司到租户 + */ +export function bindCompany(tenantId: string, tenantName?: string, erpCompanyId?: string, erpCompanyName?: string) { + return requestClient.post(Api.bindCompany, null, { params: { tenantId, tenantName, erpCompanyId, erpCompanyName } }); +} + +/** + * 解绑ERP公司 + */ +export function unbindCompany(id: number) { + return requestClient.post(`${Api.unbindCompany}/${id}`, {}); +} \ No newline at end of file diff --git a/hzhub-admin/apps/web-antd/src/views/crm/sync/index.vue b/hzhub-admin/apps/web-antd/src/views/crm/sync/index.vue new file mode 100644 index 0000000..2abd421 --- /dev/null +++ b/hzhub-admin/apps/web-antd/src/views/crm/sync/index.vue @@ -0,0 +1,586 @@ + + + \ No newline at end of file diff --git a/hzhub-erp/src/main/java/org/hzhub/erp/controller/CustomerController.java b/hzhub-erp/src/main/java/org/hzhub/erp/controller/CustomerController.java index 0bfc89e..dd556c1 100644 --- a/hzhub-erp/src/main/java/org/hzhub/erp/controller/CustomerController.java +++ b/hzhub-erp/src/main/java/org/hzhub/erp/controller/CustomerController.java @@ -79,4 +79,17 @@ public class CustomerController extends BaseController { public R> brands() { return R.ok(customerService.getBrands()); } + + /** + * 客户选择列表(用于CRM选择器) + * 支持按公司过滤(companyIds逗号分隔) + */ + @GetMapping("/select") + public TableDataInfo 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); + } } diff --git a/hzhub-erp/src/main/java/org/hzhub/erp/mapper/CustomerMapper.java b/hzhub-erp/src/main/java/org/hzhub/erp/mapper/CustomerMapper.java index 1305fe1..382efc5 100644 --- a/hzhub-erp/src/main/java/org/hzhub/erp/mapper/CustomerMapper.java +++ b/hzhub-erp/src/main/java/org/hzhub/erp/mapper/CustomerMapper.java @@ -143,4 +143,60 @@ public interface CustomerMapper extends BaseMapper { "WHERE BRAND IS NOT NULL AND BRANDNAME IS NOT NULL " + "ORDER BY BRAND") List selectBrands(); + + /** + * 客户选择列表(用于CRM选择器,支持多公司过滤) + */ + @Select("") + List selectCustomerSelectPage(@Param("pageNum") int pageNum, + @Param("pageSize") int pageSize, + @Param("keyword") String keyword, + @Param("companyIds") String companyIds, + @Param("companyIdList") List companyIdList); + + /** + * 客户选择总数(用于CRM选择器,支持多公司过滤) + */ + @Select("") + long selectCustomerSelectCount(@Param("keyword") String keyword, + @Param("companyIds") String companyIds, + @Param("companyIdList") List companyIdList); } diff --git a/hzhub-erp/src/main/java/org/hzhub/erp/service/ICustomerService.java b/hzhub-erp/src/main/java/org/hzhub/erp/service/ICustomerService.java index eb7da2e..24b2cce 100644 --- a/hzhub-erp/src/main/java/org/hzhub/erp/service/ICustomerService.java +++ b/hzhub-erp/src/main/java/org/hzhub/erp/service/ICustomerService.java @@ -30,4 +30,9 @@ public interface ICustomerService { * 获取所有品牌列表 */ List getBrands(); + + /** + * 客户选择列表(用于CRM选择器,支持多公司过滤) + */ + TableDataInfo queryCustomerSelectList(int pageNum, int pageSize, String keyword, String companyIds); } diff --git a/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/CustomerServiceImpl.java b/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/CustomerServiceImpl.java index 98b22ef..25841a6 100644 --- a/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/CustomerServiceImpl.java +++ b/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/CustomerServiceImpl.java @@ -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 getBrands() { return customerMapper.selectBrands(); } + + @Override + public TableDataInfo queryCustomerSelectList(int pageNum, int pageSize, String keyword, String companyIds) { + // 解析companyIds为列表 + List companyIdList = Collections.emptyList(); + if (companyIds != null && !companyIds.isBlank()) { + companyIdList = Arrays.asList(companyIds.split(",")); + } + + long total = customerMapper.selectCustomerSelectCount(keyword, companyIds, companyIdList); + List list = customerMapper.selectCustomerSelectPage(pageNum, pageSize, keyword, companyIds, companyIdList); + return new TableDataInfo<>(list, total); + } } diff --git a/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/DynamicApiExecutor.java b/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/DynamicApiExecutor.java index 1ca29b7..2df0929 100644 --- a/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/DynamicApiExecutor.java +++ b/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/DynamicApiExecutor.java @@ -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 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 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} diff --git a/hzhub-system/src/main/java/org/hzhub/crm/controller/CrmDealerController.java b/hzhub-system/src/main/java/org/hzhub/crm/controller/CrmDealerController.java index 2f985a3..6076e98 100644 --- a/hzhub-system/src/main/java/org/hzhub/crm/controller/CrmDealerController.java +++ b/hzhub-system/src/main/java/org/hzhub/crm/controller/CrmDealerController.java @@ -2,11 +2,21 @@ package org.hzhub.crm.controller; import lombok.RequiredArgsConstructor; import org.hzhub.common.core.domain.R; +import org.hzhub.common.mybatis.core.page.PageQuery; +import org.hzhub.common.mybatis.core.page.TableDataInfo; import org.hzhub.crm.domain.vo.CrmDealerVo; +import org.hzhub.crm.domain.vo.CrmSyncAlertVo; +import org.hzhub.crm.domain.vo.InstantSyncResult; import org.hzhub.crm.service.ICrmDealerService; +import org.hzhub.crm.service.CustomerSyncService; +import org.hzhub.crm.service.ErpIntegrationService; +import org.hzhub.crm.service.TenantCompanyService; +import org.hzhub.crm.domain.bo.CrmDealerBo; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; /** * CRM经销商管理 Controller @@ -23,6 +33,9 @@ import java.util.List; public class CrmDealerController { private final ICrmDealerService dealerService; + private final CustomerSyncService customerSyncService; + private final ErpIntegrationService erpIntegrationService; + private final TenantCompanyService tenantCompanyService; /** * 员工门户经销商选择器列表 @@ -33,4 +46,79 @@ public class CrmDealerController { List dealerList = dealerService.selectDealerList(keyword); return R.ok(dealerList); } + + /** + * CRM经销商分页列表 + */ + @GetMapping("/portal/list") + public TableDataInfo portalList(CrmDealerBo dealer, PageQuery pageQuery) { + return dealerService.selectPageDealerList(dealer, pageQuery); + } + + /** + * 获取经销商详情(含同步状态) + */ + @GetMapping("/portal/detail/{dealerId}") + public R portalDetail(@PathVariable Long dealerId) { + CrmDealerVo dealer = dealerService.selectDealerById(dealerId); + if (dealer != null) { + // 检查是否有待处理预警 + List alerts = customerSyncService.getPendingAlerts(dealerId); + dealer.setHasPendingAlerts(!alerts.isEmpty()); + } + return R.ok(dealer); + } + + /** + * ERP客户选择器数据(用于线索转化/经销商创建) + * 只返回当前租户关联公司下的客户 + */ + @GetMapping("/portal/erp-select") + public R>> erpSelect( + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "20") int pageSize) { + // 获取当前租户关联的ERP公司ID + String companyIds = tenantCompanyService.getCompanyFilterForCurrentTenant(); + List> customers = erpIntegrationService.getCustomerSelectList(keyword, companyIds); + return R.ok(customers); + } + + /** + * 校验ERP客户编码是否存在 + */ + @GetMapping("/portal/validate-code") + public R validateCustomerCode(@RequestParam String customerCode) { + boolean valid = customerSyncService.validateCustomerCode(customerCode); + return R.ok(valid); + } + + /** + * 手动同步经销商(获取ERP最新信息) + */ + @PostMapping("/portal/sync/{dealerId}") + public R manualSync(@PathVariable Long dealerId) { + InstantSyncResult result = customerSyncService.instantSync(dealerId); + return R.ok(result); + } + + /** + * 获取经销商的待处理预警列表 + */ + @GetMapping("/portal/alerts/{dealerId}") + public R> getAlerts(@PathVariable Long dealerId) { + List alerts = customerSyncService.getPendingAlerts(dealerId); + return R.ok(alerts); + } + + /** + * 处理预警 + */ + @PutMapping("/portal/alerts/{alertId}") + public R resolveAlert(@PathVariable Long alertId, + @RequestParam String action, + @RequestParam(required = false) String note) { + customerSyncService.resolveAlert(alertId, action, note); + return R.ok(); + } } \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/controller/CrmSyncController.java b/hzhub-system/src/main/java/org/hzhub/crm/controller/CrmSyncController.java new file mode 100644 index 0000000..d434f73 --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/controller/CrmSyncController.java @@ -0,0 +1,198 @@ +package org.hzhub.crm.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import lombok.RequiredArgsConstructor; +import org.hzhub.common.core.domain.R; +import org.hzhub.common.mybatis.core.page.PageQuery; +import org.hzhub.common.mybatis.core.page.TableDataInfo; +import org.hzhub.crm.domain.vo.CrmSyncAlertVo; +import org.hzhub.crm.domain.vo.CrmSyncLogVo; +import org.hzhub.crm.service.CustomerSyncService; +import org.hzhub.crm.task.CustomerSyncTask; +import org.hzhub.system.domain.SysTenantCompany; +import org.hzhub.system.domain.vo.SysTenantVo; +import org.hzhub.system.service.ISysTenantService; +import org.hzhub.crm.service.TenantCompanyService; +import org.hzhub.crm.service.ErpIntegrationService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * CRM同步管理 Controller + * 管理后台版本(需要Sa-Token权限注解) + * + * @author hzhub + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/sync") +public class CrmSyncController { + + private final CustomerSyncService customerSyncService; + private final CustomerSyncTask customerSyncTask; + private final TenantCompanyService tenantCompanyService; + private final ErpIntegrationService erpIntegrationService; + private final ISysTenantService sysTenantService; + + /** + * 获取同步配置 + */ + @SaCheckPermission("crm:sync:config") + @GetMapping("/config") + public R> getConfig() { + Map config = new java.util.HashMap<>(); + config.put("enabled", customerSyncTask.isRunning()); + config.put("cron", customerSyncTask.getCurrentCron()); + return R.ok(config); + } + + /** + * 启动定时任务 + */ + @SaCheckPermission("crm:sync:config") + @PostMapping("/start") + public R start() { + customerSyncTask.start(); + return R.ok(); + } + + /** + * 停止定时任务 + */ + @SaCheckPermission("crm:sync:config") + @PostMapping("/stop") + public R stop() { + customerSyncTask.stop(); + return R.ok(); + } + + /** + * 设置Cron表达式 + */ + @SaCheckPermission("crm:sync:config") + @PutMapping("/cron") + public R setCron(@RequestParam String cron) { + customerSyncTask.setCron(cron); + return R.ok(); + } + + /** + * 手动触发全量同步 + */ + @SaCheckPermission("crm:sync:execute") + @PostMapping("/execute") + public R execute() { + CrmSyncLogVo log = new CrmSyncLogVo(); + org.hzhub.crm.domain.CrmSyncLog syncLog = customerSyncTask.executeManualSync(); + if (syncLog != null) { + log.setId(syncLog.getId()); + log.setStatus(syncLog.getStatus()); + log.setTotalCount(syncLog.getTotalCount()); + log.setSyncedCount(syncLog.getSyncedCount()); + log.setUpdatedCount(syncLog.getUpdatedCount()); + log.setAlertCount(syncLog.getAlertCount()); + log.setErrorCount(syncLog.getErrorCount()); + log.setStartTime(syncLog.getStartTime()); + log.setEndTime(syncLog.getEndTime()); + log.setDuration(syncLog.getDuration()); + } + return R.ok(log); + } + + /** + * 同步日志列表 + */ + @SaCheckPermission("crm:sync:log") + @GetMapping("/logs") + public TableDataInfo getLogs(PageQuery pageQuery) { + return customerSyncService.listSyncLogs(pageQuery); + } + + /** + * 预警列表(全局) + */ + @SaCheckPermission("crm:sync:alert") + @GetMapping("/alerts") + public TableDataInfo getAlerts( + @RequestParam(required = false) String status, + PageQuery pageQuery) { + return customerSyncService.listAlerts(status, pageQuery); + } + + /** + * 处理预警 + */ + @SaCheckPermission("crm:sync:alert") + @PutMapping("/alerts/{alertId}") + public R resolveAlert(@PathVariable Long alertId, + @RequestParam String action, + @RequestParam(required = false) String note) { + customerSyncService.resolveAlert(alertId, action, note); + return R.ok(); + } + + /** + * 同步统计面板数据 + */ + @SaCheckPermission("crm:sync:stats") + @GetMapping("/stats") + public R> getStats() { + return R.ok(customerSyncService.getSyncStats()); + } + + // ========== 租户-公司映射管理(全局) ========== + + /** + * 获取租户列表(用于选择器) + */ + @SaCheckPermission("crm:sync:company") + @GetMapping("/tenants") + public R> getTenants() { + return R.ok(sysTenantService.queryList(new org.hzhub.system.domain.bo.SysTenantBo())); + } + + /** + * 获取所有租户的ERP公司映射列表(全局管理) + */ + @SaCheckPermission("crm:sync:company") + @GetMapping("/tenant-companies") + public R> getAllTenantCompanies() { + return R.ok(tenantCompanyService.getAllTenantCompanies()); + } + + /** + * 绑定ERP公司到租户(全局管理) + */ + @SaCheckPermission("crm:sync:company") + @PostMapping("/tenant-companies/bind") + public R bindCompany(@RequestParam String tenantId, + @RequestParam(required = false) String tenantName, + @RequestParam String erpCompanyId, + @RequestParam(required = false) String erpCompanyName) { + tenantCompanyService.bindCompany(tenantId, tenantName, erpCompanyId, erpCompanyName); + return R.ok(); + } + + /** + * 解绑ERP公司 + */ + @SaCheckPermission("crm:sync:company") + @PostMapping("/tenant-companies/unbind/{id}") + public R unbindCompany(@PathVariable Long id) { + tenantCompanyService.unbindCompany(id); + return R.ok(); + } + + /** + * 获取ERP所有公司列表(用于选择器) + */ + @SaCheckPermission("crm:sync:company") + @GetMapping("/erp-companies") + public R>> getErpCompanies() { + return R.ok(erpIntegrationService.getCompanyList()); + } +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/domain/CrmSyncAlert.java b/hzhub-system/src/main/java/org/hzhub/crm/domain/CrmSyncAlert.java new file mode 100644 index 0000000..c63bdf8 --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/domain/CrmSyncAlert.java @@ -0,0 +1,93 @@ +package org.hzhub.crm.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serial; +import java.util.Date; + +/** + * CRM同步预警对象 crm_sync_alert + * + * @author hzhub + */ +@Data +@TableName("crm_sync_alert") +public class CrmSyncAlert { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 租户编号 + */ + private String tenantId; + + /** + * 关联同步日志ID + */ + private Long syncLogId; + + /** + * 经销商ID + */ + private Long dealerId; + + /** + * ERP客户编码 + */ + private String customerCode; + + /** + * 预警类型: CONTACT_DIFF联系人差异/ADDRESS_DIFF地址详情差异/STATUS_DIFF状态差异 + */ + private String alertType; + + /** + * CRM当前值(JSON格式) + */ + private String crmValue; + + /** + * ERP当前值(JSON格式) + */ + private String erpValue; + + /** + * 预警描述 + */ + private String alertMessage; + + /** + * 处理状态: PENDING待处理/ACKNOWLEDGED已确认/RESOLVED已处理/IGNORED已忽略 + */ + private String status; + + /** + * 处理人ID + */ + private Long resolvedBy; + + /** + * 处理时间 + */ + private Date resolvedTime; + + /** + * 处理备注 + */ + private String resolvedNote; + + /** + * 创建时间 + */ + private Date createTime; +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/domain/CrmSyncLog.java b/hzhub-system/src/main/java/org/hzhub/crm/domain/CrmSyncLog.java new file mode 100644 index 0000000..690503b --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/domain/CrmSyncLog.java @@ -0,0 +1,96 @@ +package org.hzhub.crm.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hzhub.common.tenant.core.TenantEntity; + +import java.io.Serial; +import java.util.Date; + +/** + * CRM同步日志对象 crm_sync_log + * + * @author hzhub + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("crm_sync_log") +public class CrmSyncLog extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 同步类型: SCHEDULED定时/MANUAL手动/ON_EDIT编辑触发 + */ + private String syncType; + + /** + * 同步方向: ERP_TO_CRM + */ + private String syncDirection; + + /** + * 同步状态: RUNNING运行中/COMPLETED已完成/FAILED失败 + */ + private String status; + + /** + * 开始时间 + */ + private Date startTime; + + /** + * 结束时间 + */ + private Date endTime; + + /** + * 耗时(秒) + */ + private Integer duration; + + /** + * 扫描总数(绑定customerCode的经销商) + */ + private Integer totalCount; + + /** + * 已同步数量 + */ + private Integer syncedCount; + + /** + * 更新数量(ERP覆盖字段) + */ + private Integer updatedCount; + + /** + * 预警数量(CRM差异字段) + */ + private Integer alertCount; + + /** + * 错误数量 + */ + private Integer errorCount; + + /** + * 错误信息 + */ + private String errorMsg; + + /** + * 操作人 + */ + private String operator; +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmDealerVo.java b/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmDealerVo.java index 3f810ad..450c31b 100644 --- a/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmDealerVo.java +++ b/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmDealerVo.java @@ -175,4 +175,9 @@ public class CrmDealerVo implements Serializable { * 更新时间 */ private Date updateTime; + + /** + * 是否有待处理预警(扩展字段,非数据库字段) + */ + private Boolean hasPendingAlerts; } \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmSyncAlertVo.java b/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmSyncAlertVo.java new file mode 100644 index 0000000..8568e27 --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmSyncAlertVo.java @@ -0,0 +1,42 @@ +package org.hzhub.crm.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.hzhub.crm.domain.CrmSyncAlert; + +import java.io.Serial; +import java.util.Date; + +/** + * CRM同步预警视图对象 + * + * @author hzhub + */ +@Data +@AutoMapper(target = CrmSyncAlert.class) +public class CrmSyncAlertVo { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + private String tenantId; + private Long syncLogId; + private Long dealerId; + private String customerCode; + private String alertType; + private String crmValue; + private String erpValue; + private String alertMessage; + private String status; + private Long resolvedBy; + private Date resolvedTime; + private String resolvedNote; + private Date createTime; + + // 扩展字段 + private String dealerName; + private String alertTypeName; + private String statusName; + private String resolvedByName; +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmSyncLogVo.java b/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmSyncLogVo.java new file mode 100644 index 0000000..ef50fc5 --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/CrmSyncLogVo.java @@ -0,0 +1,42 @@ +package org.hzhub.crm.domain.vo; + +import io.github.linpeilie.annotations.AutoMapper; +import lombok.Data; +import org.hzhub.crm.domain.CrmSyncLog; + +import java.io.Serial; +import java.util.Date; + +/** + * CRM同步日志视图对象 + * + * @author hzhub + */ +@Data +@AutoMapper(target = CrmSyncLog.class) +public class CrmSyncLogVo { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + private String tenantId; + private String syncType; + private String syncDirection; + private String status; + private Date startTime; + private Date endTime; + private Integer duration; + private Integer totalCount; + private Integer syncedCount; + private Integer updatedCount; + private Integer alertCount; + private Integer errorCount; + private String errorMsg; + private String operator; + private Date createTime; + + // 扩展字段 + private String syncTypeName; + private String statusName; +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/InstantSyncResult.java b/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/InstantSyncResult.java new file mode 100644 index 0000000..b58f3d2 --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/domain/vo/InstantSyncResult.java @@ -0,0 +1,44 @@ +package org.hzhub.crm.domain.vo; + +import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 即时同步结果 + * + * @author hzhub + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class InstantSyncResult { + + /** + * 是否成功 + */ + private boolean success; + + /** + * 消息 + */ + private String message; + + /** + * 是否有更新(ERP覆盖字段) + */ + private boolean updated; + + /** + * 预警列表(CRM差异字段) + */ + private List alerts; + + /** + * ERP客户信息 + */ + private Map erpInfo; +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/mapper/CrmSyncAlertMapper.java b/hzhub-system/src/main/java/org/hzhub/crm/mapper/CrmSyncAlertMapper.java new file mode 100644 index 0000000..d8c829d --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/mapper/CrmSyncAlertMapper.java @@ -0,0 +1,13 @@ +package org.hzhub.crm.mapper; + +import org.hzhub.common.mybatis.core.mapper.BaseMapperPlus; +import org.hzhub.crm.domain.CrmSyncAlert; +import org.hzhub.crm.domain.vo.CrmSyncAlertVo; + +/** + * CRM同步预警Mapper + * + * @author hzhub + */ +public interface CrmSyncAlertMapper extends BaseMapperPlus { +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/mapper/CrmSyncLogMapper.java b/hzhub-system/src/main/java/org/hzhub/crm/mapper/CrmSyncLogMapper.java new file mode 100644 index 0000000..c5af51e --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/mapper/CrmSyncLogMapper.java @@ -0,0 +1,13 @@ +package org.hzhub.crm.mapper; + +import org.hzhub.common.mybatis.core.mapper.BaseMapperPlus; +import org.hzhub.crm.domain.CrmSyncLog; +import org.hzhub.crm.domain.vo.CrmSyncLogVo; + +/** + * CRM同步日志Mapper + * + * @author hzhub + */ +public interface CrmSyncLogMapper extends BaseMapperPlus { +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/service/CustomerSyncService.java b/hzhub-system/src/main/java/org/hzhub/crm/service/CustomerSyncService.java new file mode 100644 index 0000000..f660693 --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/service/CustomerSyncService.java @@ -0,0 +1,555 @@ +package org.hzhub.crm.service; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hzhub.common.core.constant.SystemConstants; +import org.hzhub.common.core.exception.ServiceException; +import org.hzhub.common.core.utils.StringUtils; +import org.hzhub.common.mybatis.core.page.PageQuery; +import org.hzhub.common.mybatis.core.page.TableDataInfo; +import org.hzhub.common.satoken.utils.LoginHelper; +import org.hzhub.common.tenant.helper.TenantHelper; +import org.hzhub.crm.domain.CrmDealer; +import org.hzhub.crm.domain.CrmSyncAlert; +import org.hzhub.crm.domain.CrmSyncLog; +import org.hzhub.crm.domain.vo.CrmSyncAlertVo; +import org.hzhub.crm.domain.vo.CrmSyncLogVo; +import org.hzhub.crm.domain.vo.InstantSyncResult; +import org.hzhub.crm.mapper.CrmDealerMapper; +import org.hzhub.crm.mapper.CrmSyncAlertMapper; +import org.hzhub.crm.mapper.CrmSyncLogMapper; +import org.hzhub.system.domain.SysTenantCompany; +import org.hzhub.system.domain.SysUser; +import org.hzhub.system.mapper.SysUserMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * CRM-ERP客户同步服务 + * + * 同步流程: + * 1. 创建同步日志 + * 2. 获取所有ERP公司ID列表(从租户-公司映射配置,不限当前租户) + * 3. 获取ERP系统中这些公司ID下的所有客户清单 + * 4. 扫描CRM中所有绑定了customerCode的经销商(用于匹配) + * 5. 逐个ERP客户同步: + * - CRM有匹配(customerCode相同) → 更新CRM字段 + * - CRM无匹配且ERP未停用 → 新增经销商记录 + * 6. 更新同步日志完成状态 + * + * @author hzhub + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class CustomerSyncService { + + private final CrmDealerMapper dealerMapper; + private final CrmSyncLogMapper syncLogMapper; + private final CrmSyncAlertMapper alertMapper; + private final ErpIntegrationService erpIntegrationService; + private final TenantCompanyService tenantCompanyService; + private final SysUserMapper userMapper; + + /** + * 执行同步(定时任务或手动触发) + * + * @param syncType 同步类型 SCHEDULED/MANUAL + * @param operator 操作人 + * @return 同步日志 + */ + @Transactional(rollbackFor = Exception.class) + public CrmSyncLog executeSync(String syncType, String operator) { + // 1. 创建同步日志 + CrmSyncLog syncLog = createSyncLog(syncType, operator); + + try { + // 2. 获取所有ERP公司ID列表(从租户-公司映射配置,不限当前租户) + List tenantCompanies = tenantCompanyService.getAllTenantCompanies(); + List companyIds = tenantCompanies.stream() + .map(SysTenantCompany::getErpCompanyId) + .filter(StringUtils::isNotBlank) + .distinct() + .collect(Collectors.toList()); + + // 构建公司ID -> 租户ID的映射(用于新增经销商时确定租户归属) + Map companyToTenantMap = tenantCompanies.stream() + .filter(tc -> StringUtils.isNotBlank(tc.getErpCompanyId()) && StringUtils.isNotBlank(tc.getTenantId())) + .collect(Collectors.toMap( + SysTenantCompany::getErpCompanyId, + SysTenantCompany::getTenantId, + (v1, v2) -> v1 // 如果同一公司映射多个租户,取第一个 + )); + + if (companyIds.isEmpty()) { + log.warn("未配置租户-公司映射,同步终止"); + syncLog.setStatus("COMPLETED"); + syncLog.setErrorMsg("未配置租户-公司映射"); + syncLog.setTotalCount(0); + finishSyncLog(syncLog); + return syncLog; + } + + // 3. 获取ERP系统中这些公司ID下的所有客户清单 + String companyIdsParam = companyIds.stream().collect(Collectors.joining(",")); + List> erpCustomers = erpIntegrationService.getCustomerSelectList(null, companyIdsParam); + syncLog.setTotalCount(erpCustomers.size()); + log.info("获取ERP客户清单: companyIds={}, count={}", companyIdsParam, erpCustomers.size()); + + // 4. 扫描CRM中所有绑定了customerCode的经销商(忽略租户过滤) + Map crmDealerMap = TenantHelper.ignore(() -> { + List dealers = dealerMapper.selectList( + new LambdaQueryWrapper() + .eq(CrmDealer::getDelFlag, SystemConstants.NORMAL) + .isNotNull(CrmDealer::getCustomerCode) + .ne(CrmDealer::getCustomerCode, "") + ); + return dealers.stream() + .collect(Collectors.toMap(CrmDealer::getCustomerCode, d -> d, (d1, d2) -> d1)); + }); + + // 5. 预加载用户昵称 -> 用户ID映射(用于匹配负责人) + Map nicknameToUserIdMap = loadUserNicknameMap(); + + int syncedCount = 0, updatedCount = 0, newCount = 0, alertCount = 0, errorCount = 0; + + // 6. 逐个ERP客户同步 + for (Map erpCustomer : erpCustomers) { + try { + String customerCode = (String) erpCustomer.get("customerCode"); + if (StringUtils.isBlank(customerCode)) { + continue; + } + + Integer isStop = (Integer) erpCustomer.get("isStop"); + boolean isErpStopped = isStop != null && isStop == 1; + + CrmDealer existingDealer = crmDealerMap.get(customerCode); + + if (existingDealer != null) { + // CRM已有匹配记录 -> 更新 + SyncResult result = syncExistingDealer(existingDealer, erpCustomer, syncLog.getId()); + syncedCount++; + if (result.updated) updatedCount++; + if (result.hasAlert) alertCount++; + } else if (!isErpStopped) { + // CRM无匹配且ERP未停用 -> 新增 + String erpCompanyCode = (String) erpCustomer.get("companyCode"); + String targetTenantId = companyToTenantMap.get(erpCompanyCode); + + if (StringUtils.isBlank(targetTenantId)) { + log.warn("ERP客户{}的公司{}未找到租户映射,跳过新增", customerCode, erpCompanyCode); + continue; + } + + SyncResult result = createNewDealer(erpCustomer, targetTenantId, nicknameToUserIdMap); + if (result.created) newCount++; + syncedCount++; + } + } catch (Exception e) { + log.error("同步ERP客户失败: customerCode={}", erpCustomer.get("customerCode"), e); + errorCount++; + } + } + + // 7. 更新日志完成状态 + syncLog.setStatus("COMPLETED"); + syncLog.setSyncedCount(syncedCount); + syncLog.setUpdatedCount(updatedCount); + syncLog.setAlertCount(alertCount); + syncLog.setErrorCount(errorCount); + + log.info("同步完成: total={}, synced={}, updated={}, new={}, alerts={}, errors={}", + syncLog.getTotalCount(), syncedCount, updatedCount, newCount, alertCount, errorCount); + + } catch (Exception e) { + log.error("同步执行失败", e); + syncLog.setStatus("FAILED"); + syncLog.setErrorMsg(e.getMessage()); + } + + finishSyncLog(syncLog); + return syncLog; + } + + /** + * 同步已存在的经销商(更新模式) + */ + private SyncResult syncExistingDealer(CrmDealer dealer, Map erpCustomer, Long syncLogId) { + SyncResult result = new SyncResult(); + boolean needsUpdate = false; + + // ERP覆盖字段同步 + // 名称 + String erpName = (String) erpCustomer.get("customerName"); + if (StringUtils.isNotBlank(erpName) && !Objects.equals(dealer.getDealerName(), erpName)) { + dealer.setDealerName(erpName); + needsUpdate = true; + result.updated = true; + } + + // 省份 + String erpProvince = (String) erpCustomer.get("province"); + if (StringUtils.isNotBlank(erpProvince) && !Objects.equals(dealer.getProvince(), erpProvince)) { + dealer.setProvince(erpProvince); + needsUpdate = true; + result.updated = true; + } + + // 城市 + String erpCity = (String) erpCustomer.get("city"); + if (StringUtils.isNotBlank(erpCity) && !Objects.equals(dealer.getCity(), erpCity)) { + dealer.setCity(erpCity); + needsUpdate = true; + result.updated = true; + } + + // 状态(ERP停用映射到CRM生命周期) + Integer erpIsStop = (Integer) erpCustomer.get("isStop"); + String targetLifecycle = (erpIsStop != null && erpIsStop == 1) ? "churn" : "active"; + if (!Objects.equals(dealer.getLifecycle(), targetLifecycle)) { + dealer.setLifecycle(targetLifecycle); + needsUpdate = true; + result.updated = true; + } + + // CRM维护字段差异检测 -> 生成预警 + checkAndCreateAlert(dealer, erpCustomer, "contactName", "CONTACT_DIFF", syncLogId, result); + checkAndCreateAlert(dealer, erpCustomer, "phone", "CONTACT_DIFF", syncLogId, result); + + // 执行更新(忽略租户过滤) + if (needsUpdate) { + dealer.setUpdateTime(new Date()); + TenantHelper.ignore(() -> dealerMapper.updateById(dealer)); + } + + return result; + } + + /** + * 新增经销商 + */ + private SyncResult createNewDealer(Map erpCustomer, String tenantId, Map nicknameToUserIdMap) { + SyncResult result = new SyncResult(); + + CrmDealer dealer = new CrmDealer(); + dealer.setTenantId(tenantId); + dealer.setCustomerCode((String) erpCustomer.get("customerCode")); + dealer.setDealerName((String) erpCustomer.get("customerName")); + dealer.setDealerCode((String) erpCustomer.get("customerCode")); // 暂用customerCode作为dealerCode + dealer.setContactName((String) erpCustomer.get("contactName")); + dealer.setMobile((String) erpCustomer.get("phone")); + dealer.setProvince((String) erpCustomer.get("province")); + dealer.setCity((String) erpCustomer.get("city")); + dealer.setLifecycle("active"); // 新增默认为活跃状态(ERP未停用的才会新增) + dealer.setDelFlag(0); // 正常状态 + dealer.setCreateTime(new Date()); + + // 匹配负责人(ERP的salesName匹配用户昵称) + String salesName = (String) erpCustomer.get("salesPersonName"); + if (StringUtils.isNotBlank(salesName) && nicknameToUserIdMap.containsKey(salesName)) { + dealer.setOwnerUserId(nicknameToUserIdMap.get(salesName)); + } + + // 在指定租户下插入(使用动态租户) + TenantHelper.dynamic(tenantId, () -> dealerMapper.insert(dealer)); + + result.created = true; + log.info("新增经销商: tenantId={}, customerCode={}, dealerName={}", tenantId, dealer.getCustomerCode(), dealer.getDealerName()); + + return result; + } + + /** + * 加载用户昵称 -> 用户ID映射 + */ + private Map loadUserNicknameMap() { + return TenantHelper.ignore(() -> { + List users = userMapper.selectList( + new LambdaQueryWrapper() + .eq(SysUser::getStatus, SystemConstants.NORMAL) + .isNotNull(SysUser::getNickName) + ); + return users.stream() + .filter(u -> StringUtils.isNotBlank(u.getNickName())) + .collect(Collectors.toMap( + SysUser::getNickName, + SysUser::getUserId, + (v1, v2) -> v1 // 昵称相同取第一个 + )); + }); + } + + /** + * 检测差异并生成预警 + */ + private void checkAndCreateAlert(CrmDealer dealer, Map erpCustomer, + String fieldName, String alertType, Long syncLogId, SyncResult result) { + String crmValue = getDealerFieldValue(dealer, fieldName); + String erpValue = getErpFieldValue(erpCustomer, fieldName); + + // CRM有值且与ERP不一致时生成预警 + if (StringUtils.isNotBlank(crmValue) && !Objects.equals(crmValue, erpValue)) { + CrmSyncAlert alert = new CrmSyncAlert(); + alert.setTenantId(dealer.getTenantId()); + alert.setSyncLogId(syncLogId); + alert.setDealerId(dealer.getDealerId()); + alert.setCustomerCode(dealer.getCustomerCode()); + alert.setAlertType(alertType); + alert.setCrmValue(crmValue); + alert.setErpValue(erpValue != null ? erpValue : ""); + alert.setAlertMessage(buildAlertMessage(fieldName, crmValue, erpValue)); + alert.setStatus("PENDING"); + alert.setCreateTime(new Date()); + + alertMapper.insert(alert); + result.hasAlert = true; + } + } + + /** + * 即时同步(编辑时调用) + */ + public InstantSyncResult instantSync(Long dealerId) { + CrmDealer dealer = TenantHelper.ignore(() -> dealerMapper.selectById(dealerId)); + if (dealer == null) { + return new InstantSyncResult(false, "经销商不存在", false, null, null); + } + + if (StringUtils.isBlank(dealer.getCustomerCode())) { + return new InstantSyncResult(false, "未绑定ERP客户编码", false, null, null); + } + + Map erpCustomer = erpIntegrationService.getCustomerDetail(dealer.getCustomerCode()); + if (erpCustomer == null) { + return new InstantSyncResult(false, "ERP客户不存在: " + dealer.getCustomerCode(), false, null, null); + } + + // 执行同步 + SyncResult result = syncExistingDealer(dealer, erpCustomer, null); + + // 获取预警列表 + List alerts = getPendingAlerts(dealerId); + + return new InstantSyncResult( + true, + result.updated ? "同步成功,已更新ERP最新信息" : "数据已是最新", + result.updated, + alerts, + erpCustomer + ); + } + + /** + * 校验ERP客户编码是否有效 + */ + public boolean validateCustomerCode(String customerCode) { + if (StringUtils.isBlank(customerCode)) { + return false; + } + Map customer = erpIntegrationService.getCustomerDetail(customerCode); + return customer != null; + } + + /** + * 获取经销商待处理预警 + */ + public List getPendingAlerts(Long dealerId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CrmSyncAlert::getDealerId, dealerId) + .eq(CrmSyncAlert::getStatus, "PENDING") + .orderByDesc(CrmSyncAlert::getCreateTime); + + List alerts = alertMapper.selectList(wrapper); + return alerts.stream() + .map(a -> BeanUtil.copyProperties(a, CrmSyncAlertVo.class)) + .collect(Collectors.toList()); + } + + /** + * 处理预警 + */ + @Transactional(rollbackFor = Exception.class) + public void resolveAlert(Long alertId, String action, String note) { + CrmSyncAlert alert = alertMapper.selectById(alertId); + if (alert == null) { + throw new ServiceException("预警不存在"); + } + + String newStatus; + switch (action) { + case "acknowledge": + newStatus = "ACKNOWLEDGED"; + break; + case "resolve": + newStatus = "RESOLVED"; + updateDealerFromAlert(alert); + break; + case "ignore": + newStatus = "IGNORED"; + break; + default: + throw new ServiceException("无效的处理操作"); + } + + alert.setStatus(newStatus); + alert.setResolvedBy(LoginHelper.getUserId()); + alert.setResolvedTime(new Date()); + alert.setResolvedNote(note); + + alertMapper.updateById(alert); + } + + /** + * 根据预警更新经销商字段 + */ + private void updateDealerFromAlert(CrmSyncAlert alert) { + TenantHelper.ignore(() -> { + CrmDealer dealer = dealerMapper.selectById(alert.getDealerId()); + if (dealer == null) { + return; + } + // CRM维护字段由用户手动处理,此方法仅记录更新时间 + dealer.setUpdateTime(new Date()); + dealerMapper.updateById(dealer); + }); + } + + /** + * 分页查询同步日志 + */ + public TableDataInfo listSyncLogs(PageQuery pageQuery) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.orderByDesc(CrmSyncLog::getStartTime); + + Page page = syncLogMapper.selectVoPage(pageQuery.build(), wrapper); + return TableDataInfo.build(page); + } + + /** + * 分页查询预警列表(全局) + */ + public TableDataInfo listAlerts(String status, PageQuery pageQuery) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (StringUtils.isNotBlank(status)) { + wrapper.eq(CrmSyncAlert::getStatus, status); + } + wrapper.orderByDesc(CrmSyncAlert::getCreateTime); + + Page page = alertMapper.selectVoPage(pageQuery.build(), wrapper); + return TableDataInfo.build(page); + } + + /** + * 获取同步统计数据 + */ + public Map getSyncStats() { + Map stats = new HashMap<>(); + + // 最近一次同步 + LambdaQueryWrapper lastWrapper = new LambdaQueryWrapper<>(); + lastWrapper.eq(CrmSyncLog::getStatus, "COMPLETED") + .orderByDesc(CrmSyncLog::getStartTime) + .last("LIMIT 1"); + CrmSyncLog lastSync = syncLogMapper.selectOne(lastWrapper); + + if (lastSync != null) { + stats.put("lastSyncTime", lastSync.getStartTime()); + stats.put("lastSyncDuration", lastSync.getDuration()); + stats.put("lastSyncCount", lastSync.getSyncedCount()); + stats.put("lastSyncUpdated", lastSync.getUpdatedCount()); + stats.put("lastSyncAlerts", lastSync.getAlertCount()); + stats.put("lastSyncErrors", lastSync.getErrorCount()); + } + + // 待处理预警数量 + Long pendingAlerts = alertMapper.selectCount( + new LambdaQueryWrapper().eq(CrmSyncAlert::getStatus, "PENDING")); + stats.put("pendingAlerts", pendingAlerts); + + // 绑定ERP编码的经销商数量(全局统计) + Long boundDealers = TenantHelper.ignore(() -> dealerMapper.selectCount( + new LambdaQueryWrapper() + .eq(CrmDealer::getDelFlag, SystemConstants.NORMAL) + .isNotNull(CrmDealer::getCustomerCode) + .ne(CrmDealer::getCustomerCode, ""))); + stats.put("boundDealers", boundDealers); + + return stats; + } + + // ========== 辅助方法 ========== + + private CrmSyncLog createSyncLog(String syncType, String operator) { + CrmSyncLog log = new CrmSyncLog(); + log.setSyncType(syncType); + log.setSyncDirection("ERP_TO_CRM"); + log.setStatus("RUNNING"); + log.setStartTime(new Date()); + log.setOperator(operator); + syncLogMapper.insert(log); + return log; + } + + private void finishSyncLog(CrmSyncLog syncLog) { + syncLog.setEndTime(new Date()); + if (syncLog.getStartTime() != null) { + syncLog.setDuration((int) ((syncLog.getEndTime().getTime() - syncLog.getStartTime().getTime()) / 1000)); + } + syncLogMapper.updateById(syncLog); + } + + private String getDealerFieldValue(CrmDealer dealer, String fieldName) { + switch (fieldName) { + case "contactName": + return dealer.getContactName(); + case "phone": + return dealer.getMobile(); + default: + return null; + } + } + + private String getErpFieldValue(Map erpCustomer, String fieldName) { + switch (fieldName) { + case "contactName": + return (String) erpCustomer.get("contactName"); + case "phone": + return (String) erpCustomer.get("phone"); + default: + return null; + } + } + + private String buildAlertMessage(String fieldName, String crmValue, String erpValue) { + String fieldLabel; + switch (fieldName) { + case "contactName": + fieldLabel = "联系人"; + break; + case "phone": + fieldLabel = "电话"; + break; + default: + fieldLabel = fieldName; + } + return String.format("%s与ERP不一致:CRM=%s, ERP=%s", fieldLabel, + crmValue != null ? crmValue : "空", + erpValue != null ? erpValue : "空"); + } + + /** + * 同步结果内部类 + */ + private static class SyncResult { + boolean updated = false; + boolean created = false; + boolean hasAlert = false; + } +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/service/ErpIntegrationService.java b/hzhub-system/src/main/java/org/hzhub/crm/service/ErpIntegrationService.java index 9210a1e..5b361a8 100644 --- a/hzhub-system/src/main/java/org/hzhub/crm/service/ErpIntegrationService.java +++ b/hzhub-system/src/main/java/org/hzhub/crm/service/ErpIntegrationService.java @@ -8,6 +8,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import java.util.ArrayList; +import java.util.List; import java.util.Map; /** @@ -26,6 +28,95 @@ public class ErpIntegrationService { private final RestTemplate restTemplate; + /** + * 获取ERP公司列表 + * + * @return 公司列表 + */ + public List> getCompanyList() { + try { + String url = erpBaseUrl + "/erp/dynamic/v1/company/list"; + log.info("调用ERP服务获取公司列表: {}", url); + + R>> response = restTemplate.getForObject(url, R.class); + if (response != null && response.getCode() == 200) { + return response.getData(); + } + + log.warn("ERP公司列表获取失败: response={}", response); + return List.of(); + } catch (Exception e) { + log.error("调用ERP服务获取公司列表异常", e); + return List.of(); + } + } + + /** + * 获取ERP公司详情 + * + * @param companyCode 公司编码 + * @return 公司信息 + */ + public Map getCompanyDetail(String companyCode) { + if (StringUtils.isBlank(companyCode)) { + return null; + } + + try { + String url = erpBaseUrl + "/erp/dynamic/v1/company/detail?companyCode=" + companyCode; + log.info("调用ERP服务获取公司详情: {}", url); + + R> response = restTemplate.getForObject(url, R.class); + if (response != null && response.getCode() == 200) { + return response.getData(); + } + + log.warn("ERP公司详情获取失败: companyCode={}, response={}", companyCode, response); + return null; + } catch (Exception e) { + log.error("调用ERP服务获取公司详情异常: companyCode={}", companyCode, e); + return null; + } + } + + /** + * 获取ERP客户选择列表(按公司过滤,用于同步) + * + * @param keyword 关键词(可选) + * @param companyIds 公司ID列表(逗号分隔) + * @return 客户列表 + */ + public List> getCustomerSelectList(String keyword, String companyIds) { + if (StringUtils.isBlank(companyIds)) { + return List.of(); + } + + // 分别查询每个公司的客户数据,然后合并 + List> result = new ArrayList<>(); + String[] companyIdArray = companyIds.split(","); + + for (String companyId : companyIdArray) { + if (StringUtils.isBlank(companyId)) { + continue; + } + try { + // 直接使用固定公司ID的SQL查询 + String url = erpBaseUrl + "/erp/dynamic/v1/customer/select?companyId=" + companyId.trim(); + log.info("调用ERP服务获取客户列表: companyId={}", companyId); + + R>> response = restTemplate.getForObject(url, R.class); + if (response != null && response.getCode() == 200 && response.getData() != null) { + result.addAll(response.getData()); + } + } catch (Exception e) { + log.error("调用ERP服务获取公司{}客户列表失败", companyId, e); + } + } + + log.info("ERP客户列表获取完成: 共{}条", result.size()); + return result; + } + /** * 获取ERP客户详情 * diff --git a/hzhub-system/src/main/java/org/hzhub/crm/service/TenantCompanyService.java b/hzhub-system/src/main/java/org/hzhub/crm/service/TenantCompanyService.java new file mode 100644 index 0000000..a079198 --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/service/TenantCompanyService.java @@ -0,0 +1,209 @@ +package org.hzhub.crm.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hzhub.common.core.exception.ServiceException; +import org.hzhub.common.core.utils.StringUtils; +import org.hzhub.common.satoken.utils.LoginHelper; +import org.hzhub.system.domain.SysTenantCompany; +import org.hzhub.system.mapper.SysTenantCompanyMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 租户与ERP公司映射服务 + * + * @author hzhub + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class TenantCompanyService { + + private final SysTenantCompanyMapper tenantCompanyMapper; + private final ErpIntegrationService erpIntegrationService; + + /** + * 获取当前租户关联的所有ERP公司ID列表 + * + * @return ERP公司ID列表 + */ + public List getCurrentTenantCompanyIds() { + String tenantId = LoginHelper.getTenantId(); + return getTenantCompanyIds(tenantId); + } + + /** + * 获取指定租户关联的所有ERP公司ID列表 + * + * @param tenantId 租户编号 + * @return ERP公司ID列表 + */ + public List getTenantCompanyIds(String tenantId) { + if (StringUtils.isBlank(tenantId)) { + return Collections.emptyList(); + } + + List mappings = tenantCompanyMapper.selectList( + new LambdaQueryWrapper() + .eq(SysTenantCompany::getTenantId, tenantId) + .eq(SysTenantCompany::getStatus, "0") + .orderByAsc(SysTenantCompany::getSort) + ); + + return mappings.stream() + .map(SysTenantCompany::getErpCompanyId) + .filter(StringUtils::isNotBlank) + .collect(Collectors.toList()); + } + + /** + * 获取当前租户关联的所有ERP公司映射列表 + * + * @return 映射列表 + */ + public List getCurrentTenantCompanies() { + String tenantId = LoginHelper.getTenantId(); + return tenantCompanyMapper.selectList( + new LambdaQueryWrapper() + .eq(SysTenantCompany::getTenantId, tenantId) + .orderByAsc(SysTenantCompany::getSort) + ); + } + + /** + * 获取所有租户的ERP公司映射列表(全局管理) + * + * @return 映射列表 + */ + public List getAllTenantCompanies() { + return tenantCompanyMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(SysTenantCompany::getTenantId) + .orderByAsc(SysTenantCompany::getSort) + ); + } + + /** + * 绑定租户与ERP公司 + * + * @param tenantId 租户编号 + * @param tenantName 租户名称(可选) + * @param erpCompanyId ERP公司ID + * @param erpCompanyName ERP公司名称(可选) + * @return 绑定结果 + */ + @Transactional(rollbackFor = Exception.class) + public int bindCompany(String tenantId, String tenantName, String erpCompanyId, String erpCompanyName) { + if (StringUtils.isBlank(tenantId) || StringUtils.isBlank(erpCompanyId)) { + throw new ServiceException("租户编号和ERP公司ID不能为空"); + } + + // 检查是否已绑定 + SysTenantCompany existing = tenantCompanyMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysTenantCompany::getTenantId, tenantId) + .eq(SysTenantCompany::getErpCompanyId, erpCompanyId) + ); + + if (existing != null) { + if ("0".equals(existing.getStatus())) { + throw new ServiceException("该ERP公司已绑定到该租户"); + } + // 恢复已停用的绑定 + existing.setStatus("0"); + if (StringUtils.isNotBlank(erpCompanyName)) { + existing.setErpCompanyName(erpCompanyName); + } + if (StringUtils.isNotBlank(tenantName)) { + existing.setTenantName(tenantName); + } + return tenantCompanyMapper.updateById(existing); + } + + // 新建绑定 + SysTenantCompany mapping = new SysTenantCompany(); + mapping.setTenantId(tenantId); + mapping.setTenantName(tenantName); + mapping.setErpCompanyId(erpCompanyId); + mapping.setErpCompanyName(erpCompanyName); + mapping.setStatus("0"); + mapping.setSort(0); + + return tenantCompanyMapper.insert(mapping); + } + + /** + * 解绑租户与ERP公司(停用状态) + * + * @param id 映射ID + * @return 解绑结果 + */ + @Transactional(rollbackFor = Exception.class) + public int unbindCompany(Long id) { + SysTenantCompany mapping = tenantCompanyMapper.selectById(id); + if (mapping == null) { + throw new ServiceException("映射关系不存在"); + } + + mapping.setStatus("1"); + return tenantCompanyMapper.updateById(mapping); + } + + /** + * 删除租户与ERP公司映射 + * + * @param id 映射ID + * @return 删除结果 + */ + @Transactional(rollbackFor = Exception.class) + public int deleteMapping(Long id) { + return tenantCompanyMapper.deleteById(id); + } + + /** + * 校验ERP客户是否属于当前租户关联的公司 + * + * @param customerCode ERP客户编码 + * @return 是否属于 + */ + public boolean isCustomerBelongsToCurrentTenant(String customerCode) { + if (StringUtils.isBlank(customerCode)) { + return false; + } + + List companyIds = getCurrentTenantCompanyIds(); + if (companyIds.isEmpty()) { + // 如果租户没有配置公司映射,暂时允许(后续可改为拒绝) + log.warn("租户[{}]未配置ERP公司映射,暂时允许所有客户", LoginHelper.getTenantId()); + return true; + } + + // 获取ERP客户详情,检查其companyCode + var customerDetail = erpIntegrationService.getCustomerDetail(customerCode); + if (customerDetail == null) { + return false; + } + + String customerCompanyCode = (String) customerDetail.get("companyCode"); + return companyIds.contains(customerCompanyCode); + } + + /** + * 获取租户的ERP公司筛选条件(用于ERP客户查询) + * + * @return companyIds字符串(逗号分隔),如果未配置返回null + */ + public String getCompanyFilterForCurrentTenant() { + List companyIds = getCurrentTenantCompanyIds(); + if (companyIds.isEmpty()) { + return null; + } + return companyIds.stream().collect(Collectors.joining(",")); + } +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/service/impl/CrmDealerServiceImpl.java b/hzhub-system/src/main/java/org/hzhub/crm/service/impl/CrmDealerServiceImpl.java index f9dac60..26d4f10 100644 --- a/hzhub-system/src/main/java/org/hzhub/crm/service/impl/CrmDealerServiceImpl.java +++ b/hzhub-system/src/main/java/org/hzhub/crm/service/impl/CrmDealerServiceImpl.java @@ -17,6 +17,8 @@ import org.hzhub.crm.domain.bo.CrmDealerBo; import org.hzhub.crm.domain.vo.CrmDealerVo; import org.hzhub.crm.mapper.CrmDealerMapper; import org.hzhub.crm.service.ICrmDealerService; +import org.hzhub.crm.service.CustomerSyncService; +import org.hzhub.crm.service.ErpIntegrationService; import org.springframework.stereotype.Service; import java.util.List; @@ -33,6 +35,8 @@ import java.util.Map; public class CrmDealerServiceImpl implements ICrmDealerService { private final CrmDealerMapper dealerMapper; + private final CustomerSyncService customerSyncService; + private final ErpIntegrationService erpIntegrationService; @Override public TableDataInfo selectPageDealerList(CrmDealerBo dealer, PageQuery pageQuery) { @@ -73,7 +77,32 @@ public class CrmDealerServiceImpl implements ICrmDealerService { throw new ServiceException("经销商编码已存在"); } + // 【新增】校验customerCode必填且存在 + if (StringUtils.isBlank(dealer.getCustomerCode())) { + throw new ServiceException("经销商必须绑定ERP客户编码"); + } + + if (!customerSyncService.validateCustomerCode(dealer.getCustomerCode())) { + throw new ServiceException("ERP客户不存在或不属于当前租户: " + dealer.getCustomerCode()); + } + CrmDealer crmDealer = MapstructUtils.convert(dealer, CrmDealer.class); + + // 【新增】从ERP拉取名称和区域信息覆盖 + var erpCustomer = erpIntegrationService.getCustomerDetail(dealer.getCustomerCode()); + if (erpCustomer != null) { + String erpName = (String) erpCustomer.get("customerName"); + if (StringUtils.isNotBlank(erpName)) { + crmDealer.setDealerName(erpName); + } + crmDealer.setProvince((String) erpCustomer.get("province")); + crmDealer.setCity((String) erpCustomer.get("city")); + + // 状态映射 + Integer erpIsStop = (Integer) erpCustomer.get("isStop"); + crmDealer.setLifecycle((erpIsStop != null && erpIsStop == 1) ? "churn" : "active"); + } + return dealerMapper.insert(crmDealer); } diff --git a/hzhub-system/src/main/java/org/hzhub/crm/service/impl/CrmLeadServiceImpl.java b/hzhub-system/src/main/java/org/hzhub/crm/service/impl/CrmLeadServiceImpl.java index eff6946..5b482e3 100644 --- a/hzhub-system/src/main/java/org/hzhub/crm/service/impl/CrmLeadServiceImpl.java +++ b/hzhub-system/src/main/java/org/hzhub/crm/service/impl/CrmLeadServiceImpl.java @@ -24,6 +24,7 @@ import org.hzhub.crm.domain.bo.CrmLeadConvertBo; import org.hzhub.crm.domain.bo.CrmLeadFollowBo; import org.hzhub.crm.domain.vo.CrmDealerVo; import org.hzhub.crm.domain.vo.CrmLeadFollowVo; +import org.hzhub.crm.domain.vo.CrmLeadStatsVo; import org.hzhub.crm.domain.vo.CrmLeadVo; import org.hzhub.crm.mapper.CrmDealerMapper; import org.hzhub.crm.mapper.CrmLeadFollowMapper; @@ -31,9 +32,13 @@ import org.hzhub.crm.mapper.CrmLeadMapper; import org.hzhub.crm.service.ErpIntegrationService; import org.hzhub.crm.service.ICrmLeadService; import org.hzhub.crm.service.ICrmOpportunityService; +import org.hzhub.crm.service.CustomerSyncService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; import java.util.Date; import java.util.List; import java.util.Map; @@ -53,6 +58,7 @@ public class CrmLeadServiceImpl implements ICrmLeadService { private final CrmDealerMapper dealerMapper; private final ErpIntegrationService erpIntegrationService; private final ICrmOpportunityService opportunityService; + private final CustomerSyncService customerSyncService; @Override public TableDataInfo selectPageLeadList(CrmLeadBo lead, PageQuery pageQuery) { @@ -91,6 +97,67 @@ public class CrmLeadServiceImpl implements ICrmLeadService { return lead; } + @Override + public CrmLeadStatsVo getLeadStats() { + String tenantId = LoginHelper.getTenantId(); + + CrmLeadStatsVo stats = new CrmLeadStatsVo(); + + // 总数统计 + Long totalCount = leadMapper.countTotal(tenantId); + stats.setTotalCount(totalCount); + + // 高意向线索数量 + Long highIntentCount = leadMapper.countHighIntent(tenantId); + stats.setHighIntentCount(highIntentCount); + + // 本月/上月时间计算 + LocalDate now = LocalDate.now(); + int currentYear = now.getYear(); + int currentMonth = now.getMonthValue(); + int lastMonth = currentMonth == 1 ? 12 : currentMonth - 1; + int lastMonthYear = currentMonth == 1 ? currentYear - 1 : currentYear; + + // 本月新增 + Long monthlyNewCount = leadMapper.countMonthlyNew(tenantId, currentYear, currentMonth); + stats.setMonthlyNewCount(monthlyNewCount); + + // 上月新增 + Long lastMonthNewCount = leadMapper.countMonthlyNew(tenantId, lastMonthYear, lastMonth); + stats.setLastMonthNewCount(lastMonthNewCount); + + // 已转化数量 + Long convertedCount = leadMapper.countConverted(tenantId); + stats.setConvertedCount(convertedCount); + + // 转化率 + if (totalCount > 0) { + BigDecimal rate = BigDecimal.valueOf(convertedCount * 100) + .divide(BigDecimal.valueOf(totalCount), 2, RoundingMode.HALF_UP); + stats.setConversionRate(rate); + } else { + stats.setConversionRate(BigDecimal.ZERO); + } + + // 本月转化 + Long monthlyConvertedCount = leadMapper.countMonthlyConverted(tenantId, currentYear, currentMonth); + stats.setMonthlyConvertedCount(monthlyConvertedCount); + + // 上月转化 + Long lastMonthConvertedCount = leadMapper.countMonthlyConverted(tenantId, lastMonthYear, lastMonth); + stats.setLastMonthConvertedCount(lastMonthConvertedCount); + + // 本月高意向 + Long monthlyHighIntentCount = leadMapper.countMonthlyHighIntent(tenantId, currentYear, currentMonth); + stats.setMonthlyHighIntentCount(monthlyHighIntentCount); + + // 上月高意向 + Long lastMonthHighIntentCount = leadMapper.countMonthlyHighIntent(tenantId, lastMonthYear, lastMonth); + stats.setLastMonthHighIntentCount(lastMonthHighIntentCount); + + return stats; + } + @Override @Transactional(rollbackFor = Exception.class) public int insertLead(CrmLeadBo lead) { @@ -261,17 +328,42 @@ public class CrmLeadServiceImpl implements ICrmLeadService { throw new ServiceException("经销商编码已存在"); } - // 3. 创建经销商 + // 3. 【新增】校验ERP客户编码必须存在且属于当前租户 + if (StringUtils.isBlank(convert.getCustomerCode())) { + throw new ServiceException("经销商必须绑定ERP客户编码,请选择ERP客户"); + } + + if (!customerSyncService.validateCustomerCode(convert.getCustomerCode())) { + throw new ServiceException("ERP客户不存在或不属于当前租户: " + convert.getCustomerCode()); + } + + // 4. 创建经销商 CrmDealer dealer = new CrmDealer(); - dealer.setCustomerCode(convert.getCustomerCode()); - dealer.setDealerName(convert.getDealerName()); + dealer.setCustomerCode(convert.getCustomerCode()); // 必填 + + // 【新增】从ERP拉取客户信息覆盖名称和区域 + Map erpCustomer = erpIntegrationService.getCustomerDetail(convert.getCustomerCode()); + if (erpCustomer != null) { + // 名称使用ERP值 + String erpName = (String) erpCustomer.get("customerName"); + dealer.setDealerName(StringUtils.isNotBlank(erpName) ? erpName : convert.getDealerName()); + + // 区域使用ERP值 + dealer.setProvince((String) erpCustomer.get("province")); + dealer.setCity((String) erpCustomer.get("city")); + + // 状态映射 + Integer erpIsStop = (Integer) erpCustomer.get("isStop"); + dealer.setLifecycle((erpIsStop != null && erpIsStop == 1) ? "churn" : "active"); + } else { + dealer.setDealerName(convert.getDealerName()); + dealer.setLifecycle("active"); + } + dealer.setDealerCode(convert.getDealerCode()); dealer.setContactName(lead.getContactName()); dealer.setMobile(lead.getMobile()); - dealer.setProvince(lead.getProvince()); - dealer.setCity(lead.getCity()); dealer.setLevel(convert.getLevel() != null ? convert.getLevel() : "C"); - dealer.setLifecycle("active"); // 处理签约时间 if (convert.getSignedAt() != null) { @@ -290,14 +382,72 @@ public class CrmLeadServiceImpl implements ICrmLeadService { throw new ServiceException("创建经销商失败"); } - // 4. 更新线索状态 + // 5. 更新线索状态 lead.setLeadStatus("converted"); lead.setConvertedDealerId(dealer.getDealerId()); int leadResult = leadMapper.updateById(lead); - log.info("线索转化成功: leadId={}, dealerId={}, dealerCode={}", - lead.getLeadId(), dealer.getDealerId(), dealer.getDealerCode()); + log.info("线索转化成功: leadId={}, dealerId={}, dealerCode={}, customerCode={}", + lead.getLeadId(), dealer.getDealerId(), dealer.getDealerCode(), dealer.getCustomerCode()); return leadResult; } + + /** + * 作废线索 + * + * @param leadId 线索ID + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int invalidateLead(Long leadId) { + // 查询线索 + CrmLead lead = leadMapper.selectById(leadId); + if (lead == null) { + throw new ServiceException("线索不存在"); + } + if ("converted".equals(lead.getLeadStatus())) { + throw new ServiceException("线索已转化,不能作废"); + } + if ("invalid".equals(lead.getLeadStatus())) { + throw new ServiceException("线索已作废"); + } + + // 更新状态为作废 + return leadMapper.update(null, + new LambdaUpdateWrapper() + .set(CrmLead::getLeadStatus, "invalid") + .set(CrmLead::getUpdateTime, new Date()) + .set(CrmLead::getUpdateBy, LoginHelper.getUserId()) + .eq(CrmLead::getLeadId, leadId)); + } + + /** + * 恢复线索 + * + * @param leadId 线索ID + * @return 结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public int restoreLead(Long leadId) { + // 查询线索 + CrmLead lead = leadMapper.selectById(leadId); + if (lead == null) { + throw new ServiceException("线索不存在"); + } + if (!"invalid".equals(lead.getLeadStatus())) { + throw new ServiceException("只有已作废的线索才能恢复"); + } + + // 恢复状态为新线索或跟进中(根据是否有负责人) + String newStatus = lead.getOwnerUserId() != null ? "following" : "new"; + return leadMapper.update(null, + new LambdaUpdateWrapper() + .set(CrmLead::getLeadStatus, newStatus) + .set(CrmLead::getUpdateTime, new Date()) + .set(CrmLead::getUpdateBy, LoginHelper.getUserId()) + .eq(CrmLead::getLeadId, leadId)); + } } \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/crm/task/CustomerSyncTask.java b/hzhub-system/src/main/java/org/hzhub/crm/task/CustomerSyncTask.java new file mode 100644 index 0000000..220f837 --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/crm/task/CustomerSyncTask.java @@ -0,0 +1,184 @@ +package org.hzhub.crm.task; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hzhub.common.tenant.helper.TenantHelper; +import org.hzhub.crm.domain.CrmSyncLog; +import org.hzhub.crm.service.CustomerSyncService; +import org.hzhub.crm.service.TenantCompanyService; +import org.hzhub.system.domain.SysTenant; +import org.hzhub.system.mapper.SysTenantMapper; +import org.hzhub.system.service.ISysConfigService; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.support.CronTrigger; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * CRM-ERP客户同步定时任务(动态调度,多租户独立) + *

支持运行时启动/停止/修改频率,配置存储在 sys_config 表中

+ *

每个租户独立执行,未配置ERP公司映射的租户自动跳过

+ * + * @author hzhub + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomerSyncTask { + + private static final String CRON_CONFIG_KEY = "crm.sync.cron"; + private static final String ENABLED_CONFIG_KEY = "crm.sync.enabled"; + + private final CustomerSyncService customerSyncService; + private final TenantCompanyService tenantCompanyService; + private final ISysConfigService configService; + private final SysTenantMapper tenantMapper; + private final TaskScheduler taskScheduler; + + private volatile ScheduledFuture scheduledFuture; + + /** + * 应用启动完成后,如果配置为启用则自动启动定时任务 + */ + @EventListener(ApplicationReadyEvent.class) + public void init() { + try { + String enabled = configService.selectConfigByKey(ENABLED_CONFIG_KEY); + if ("true".equals(enabled)) { + start(); + } + } catch (Exception e) { + log.warn("初始化CRM同步定时任务失败: {}", e.getMessage()); + } + } + + /** + * 启动定时任务 + */ + public synchronized void start() { + if (scheduledFuture != null && !scheduledFuture.isCancelled()) { + log.info("CRM同步定时任务已在运行中"); + return; + } + + String cron = configService.selectConfigByKey(CRON_CONFIG_KEY); + if (cron == null || cron.isBlank()) { + cron = "0 0 2 * * ?"; // 默认每日凌晨2点 + } + + log.info("启动CRM同步定时任务, cron: {}", cron); + scheduledFuture = taskScheduler.schedule(() -> { + try { + log.info("[定时任务] 开始执行CRM-ERP客户同步(多租户)"); + syncAllTenants(); + log.info("[定时任务] CRM客户同步全部完成"); + } catch (Exception e) { + log.error("[定时任务] CRM客户同步异常", e); + } + }, new CronTrigger(cron)); + + configService.updateConfigByKey(ENABLED_CONFIG_KEY, "true"); + } + + /** + * 停止定时任务 + */ + public synchronized void stop() { + if (scheduledFuture == null) { + log.info("CRM同步定时任务未启动"); + return; + } + + log.info("停止CRM同步定时任务"); + scheduledFuture.cancel(false); + scheduledFuture = null; + configService.updateConfigByKey(ENABLED_CONFIG_KEY, "false"); + } + + /** + * 检查是否正在运行 + */ + public boolean isRunning() { + return scheduledFuture != null && !scheduledFuture.isCancelled(); + } + + /** + * 获取当前 cron 表达式 + */ + public String getCurrentCron() { + return configService.selectConfigByKey(CRON_CONFIG_KEY); + } + + /** + * 设置 cron 表达式 + */ + public synchronized void setCron(String cron) { + configService.updateConfigByKey(CRON_CONFIG_KEY, cron); + // 如果正在运行,重启以应用新 cron + if (isRunning()) { + stop(); + start(); + } + } + + /** + * 遍历所有租户,依次执行同步 + */ + private void syncAllTenants() { + List tenants = tenantMapper.selectList( + new LambdaQueryWrapper() + .eq(SysTenant::getStatus, "0") + ); + + log.info("共发现 {} 个有效租户,开始逐个执行CRM同步", tenants.size()); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger skipCount = new AtomicInteger(0); + AtomicInteger errorCount = new AtomicInteger(0); + + for (SysTenant tenant : tenants) { + final String tenantId = tenant.getTenantId(); + try { + TenantHelper.dynamic(tenantId, () -> { + // 检查租户是否配置了ERP公司映射 + List companyIds = tenantCompanyService.getTenantCompanyIds(tenantId); + if (companyIds.isEmpty()) { + log.info("租户[{}]未配置ERP公司映射,跳过同步", tenantId); + skipCount.incrementAndGet(); + return; + } + + log.info("租户[{}]开始CRM-ERP客户同步,关联{}家公司", tenantId, companyIds.size()); + try { + CrmSyncLog syncLog = customerSyncService.executeSync("SCHEDULED", "SYSTEM"); + log.info("租户[{}]同步完成: 扫描={}, 同步={}, 更新={}, 预警={}, 错误={}", + tenantId, syncLog.getTotalCount(), syncLog.getSyncedCount(), + syncLog.getUpdatedCount(), syncLog.getAlertCount(), syncLog.getErrorCount()); + successCount.incrementAndGet(); + } catch (Exception e) { + log.error("租户[{}]CRM同步失败: {}", tenantId, e.getMessage(), e); + errorCount.incrementAndGet(); + } + }); + } catch (Exception e) { + log.error("切换租户[{}]上下文失败", tenantId, e); + errorCount.incrementAndGet(); + } + } + + log.info("[定时任务] 多租户CRM同步完成: 成功={}, 跳过={}, 失败={}", successCount.get(), skipCount.get(), errorCount.get()); + } + + /** + * 手动触发一次同步(管理员调用) + */ + public CrmSyncLog executeManualSync() { + return customerSyncService.executeSync("MANUAL", "ADMIN"); + } +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/system/domain/SysTenantCompany.java b/hzhub-system/src/main/java/org/hzhub/system/domain/SysTenantCompany.java new file mode 100644 index 0000000..1232976 --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/system/domain/SysTenantCompany.java @@ -0,0 +1,60 @@ +package org.hzhub.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hzhub.common.mybatis.core.domain.BaseEntity; + +import java.io.Serial; + +/** + * 租户与ERP公司映射对象 sys_tenant_company + * + * @author hzhub + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("sys_tenant_company") +public class SysTenantCompany extends BaseEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 主键ID + */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 租户编号 + */ + private String tenantId; + + /** + * 租户名称 + */ + private String tenantName; + + /** + * ERP公司ID(companyid) + */ + private String erpCompanyId; + + /** + * ERP公司名称 + */ + private String erpCompanyName; + + /** + * 排序 + */ + private Integer sort; + + /** + * 状态(0正常 1停用) + */ + private String status; +} \ No newline at end of file diff --git a/hzhub-system/src/main/java/org/hzhub/system/mapper/SysTenantCompanyMapper.java b/hzhub-system/src/main/java/org/hzhub/system/mapper/SysTenantCompanyMapper.java new file mode 100644 index 0000000..167fe1f --- /dev/null +++ b/hzhub-system/src/main/java/org/hzhub/system/mapper/SysTenantCompanyMapper.java @@ -0,0 +1,12 @@ +package org.hzhub.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.hzhub.system.domain.SysTenantCompany; + +/** + * 租户与ERP公司映射Mapper + * + * @author hzhub + */ +public interface SysTenantCompanyMapper extends BaseMapper { +} \ No newline at end of file diff --git a/hzhub-system/src/main/resources/application.yml b/hzhub-system/src/main/resources/application.yml index 5c01ba1..9234b62 100644 --- a/hzhub-system/src/main/resources/application.yml +++ b/hzhub-system/src/main/resources/application.yml @@ -124,6 +124,7 @@ tenant: - sys_menu - sys_tenant - sys_tenant_package + - sys_tenant_company - sys_role_dept - sys_role_menu - sys_user_post @@ -131,8 +132,8 @@ tenant: - sys_client - sys_oss_config - flow_spel - - sys_dict_type - - sys_dict_data + - crm_sync_log + - crm_sync_alert # MyBatis-Plus 配置 mybatis-plus: @@ -255,3 +256,7 @@ gen: # 企业微信配置(corpid/corpsecret 等租户级配置已迁移至数据库 wecom_tenant_config 表) wecom: api-base-url: https://qyapi.weixin.qq.com + +# ERP服务配置 +erp: + base-url: ${ERP_BASE_URL:http://localhost:8082} diff --git a/hzhub-system/src/main/resources/db/crm_sync_init.sql b/hzhub-system/src/main/resources/db/crm_sync_init.sql new file mode 100644 index 0000000..d81c85d --- /dev/null +++ b/hzhub-system/src/main/resources/db/crm_sync_init.sql @@ -0,0 +1,150 @@ +-- CRM与ERP客户同步功能初始化脚本 +-- 执行说明:在MySQL中执行此脚本创建同步相关表 + +-- ======================================== +-- 0. 租户与ERP公司映射表 (sys_tenant_company) +-- 用于配置租户与ERP companyid的一对多关系 +-- ======================================== +CREATE TABLE IF NOT EXISTS `sys_tenant_company` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `tenant_id` VARCHAR(20) NOT NULL COMMENT '租户编号', + `erp_company_id` VARCHAR(50) NOT NULL COMMENT 'ERP公司ID(companyid)', + `erp_company_name` VARCHAR(100) DEFAULT NULL COMMENT 'ERP公司名称', + `sort` INT DEFAULT 0 COMMENT '排序', + `status` CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)', + `create_dept` BIGINT DEFAULT NULL COMMENT '创建部门', + `create_by` BIGINT DEFAULT NULL COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` BIGINT DEFAULT NULL COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tenant_company` (`tenant_id`, `erp_company_id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_erp_company_id` (`erp_company_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户与ERP公司映射表'; + +-- ======================================== +-- 1. 同步日志表 (crm_sync_log) +-- ======================================== +CREATE TABLE IF NOT EXISTS `crm_sync_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `tenant_id` VARCHAR(20) DEFAULT '000000' COMMENT '租户编号', + `sync_type` VARCHAR(20) NOT NULL COMMENT '同步类型: SCHEDULED定时/MANUAL手动/ON_EDIT编辑触发', + `sync_direction` VARCHAR(20) NOT NULL COMMENT '同步方向: ERP_TO_CRM', + `status` VARCHAR(20) DEFAULT 'RUNNING' COMMENT '同步状态: RUNNING运行中/COMPLETED已完成/FAILED失败', + `start_time` DATETIME NOT NULL COMMENT '开始时间', + `end_time` DATETIME DEFAULT NULL COMMENT '结束时间', + `duration` INT DEFAULT NULL COMMENT '耗时(秒)', + `total_count` INT DEFAULT 0 COMMENT '扫描总数(绑定customerCode的经销商)', + `synced_count` INT DEFAULT 0 COMMENT '已同步数量', + `updated_count` INT DEFAULT 0 COMMENT '更新数量(ERP覆盖字段)', + `alert_count` INT DEFAULT 0 COMMENT '预警数量(CRM差异字段)', + `error_count` INT DEFAULT 0 COMMENT '错误数量', + `error_msg` TEXT DEFAULT NULL COMMENT '错误信息', + `operator` VARCHAR(64) DEFAULT NULL COMMENT '操作人', + `create_dept` BIGINT DEFAULT NULL COMMENT '创建部门', + `create_by` BIGINT DEFAULT NULL COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` BIGINT DEFAULT NULL COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_sync_type` (`sync_type`), + KEY `idx_start_time` (`start_time`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CRM同步日志表'; + +-- ======================================== +-- 2. 同步预警表 (crm_sync_alert) +-- ======================================== +CREATE TABLE IF NOT EXISTS `crm_sync_alert` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `tenant_id` VARCHAR(20) DEFAULT '000000' COMMENT '租户编号', + `sync_log_id` BIGINT NOT NULL COMMENT '关联同步日志ID', + `dealer_id` BIGINT NOT NULL COMMENT '经销商ID', + `customer_code` VARCHAR(100) NOT NULL COMMENT 'ERP客户编码', + `alert_type` VARCHAR(20) NOT NULL COMMENT '预警类型: CONTACT_DIFF联系人差异/ADDRESS_DIFF地址详情差异/STATUS_DIFF状态差异', + `crm_value` TEXT DEFAULT NULL COMMENT 'CRM当前值(JSON格式)', + `erp_value` TEXT DEFAULT NULL COMMENT 'ERP当前值(JSON格式)', + `alert_message` VARCHAR(500) DEFAULT NULL COMMENT '预警描述', + `status` VARCHAR(20) DEFAULT 'PENDING' COMMENT '处理状态: PENDING待处理/ACKNOWLEDGED已确认/RESOLVED已处理/IGNORED已忽略', + `resolved_by` BIGINT DEFAULT NULL COMMENT '处理人ID', + `resolved_time` DATETIME DEFAULT NULL COMMENT '处理时间', + `resolved_note` VARCHAR(500) DEFAULT NULL COMMENT '处理备注', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_tenant_id` (`tenant_id`), + KEY `idx_sync_log_id` (`sync_log_id`), + KEY `idx_dealer_id` (`dealer_id`), + KEY `idx_customer_code` (`customer_code`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CRM同步预警表'; + +-- ======================================== +-- 3. 系统配置项 (sys_config) +-- ======================================== +INSERT INTO `sys_config` (`config_id`, `config_name`, `config_key`, `config_value`, `config_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2046502099104342019, 'CRM同步定时任务启用', 'crm.sync.enabled', 'false', 'Y', 103, 1, NOW(), 'CRM-ERP客户同步定时任务是否启用'), +(2046502099104342020, 'CRM同步定时任务Cron表达式', 'crm.sync.cron', '0 0 2 * * ?', 'Y', 103, 1, NOW(), 'CRM-ERP客户同步定时任务执行时间,默认每日凌晨2点'); + +-- ======================================== +-- 4. 字典类型 (sys_dict_type + sys_dict_data) +-- ======================================== +-- 同步类型字典 +INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2056922814748053507, 'CRM同步类型', 'crm_sync_type', 103, 1, NOW(), 'CRM-ERP客户同步类型'); + +INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2056922815037460485, 1, '定时同步', 'SCHEDULED', 'crm_sync_type', '', 'primary', 'Y', 103, 1, NOW(), '定时任务自动执行'), +(2056922815037460486, 2, '手动同步', 'MANUAL', 'crm_sync_type', '', 'success', 'N', 103, 1, NOW(), '管理员手动触发'), +(2056922815037460487, 3, '编辑触发', 'ON_EDIT', 'crm_sync_type', '', 'info', 'N', 103, 1, NOW(), '编辑保存时触发校验'); + +-- 同步状态字典 +INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2056922814748053508, 'CRM同步状态', 'crm_sync_status', 103, 1, NOW(), 'CRM-ERP客户同步执行状态'); + +INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2056922815037460488, 1, '运行中', 'RUNNING', 'crm_sync_status', '', 'primary', 'Y', 103, 1, NOW(), '同步任务正在执行'), +(2056922815037460489, 2, '已完成', 'COMPLETED', 'crm_sync_status', '', 'success', 'N', 103, 1, NOW(), '同步任务执行完成'), +(2056922815037460490, 3, '失败', 'FAILED', 'crm_sync_status', '', 'danger', 'N', 103, 1, NOW(), '同步任务执行失败'); + +-- 预警类型字典 +INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2056922814748053509, 'CRM同步预警类型', 'crm_sync_alert_type', 103, 1, NOW(), 'CRM-ERP客户同步预警类型'); + +INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2056922815037460491, 1, '联系人差异', 'CONTACT_DIFF', 'crm_sync_alert_type', '', 'warning', 'N', 103, 1, NOW(), '联系人/电话与ERP不一致'), +(2056922815037460492, 2, '地址差异', 'ADDRESS_DIFF', 'crm_sync_alert_type', '', 'warning', 'N', 103, 1, NOW(), '地址详情与ERP不一致'), +(2056922815037460493, 3, '状态差异', 'STATUS_DIFF', 'crm_sync_alert_type', '', 'danger', 'N', 103, 1, NOW(), 'ERP客户已停用'); + +-- 预警处理状态字典 +INSERT INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2056922814748053510, 'CRM预警处理状态', 'crm_sync_alert_status', 103, 1, NOW(), 'CRM-ERP客户同步预警处理状态'); + +INSERT INTO `sys_dict_data` (`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2056922815037460494, 1, '待处理', 'PENDING', 'crm_sync_alert_status', '', 'warning', 'Y', 103, 1, NOW(), '预警待人工处理'), +(2056922815037460495, 2, '已确认', 'ACKNOWLEDGED', 'crm_sync_alert_status', '', 'info', 'N', 103, 1, NOW(), '已确认差异,保持CRM值'), +(2056922815037460496, 3, '已处理', 'RESOLVED', 'crm_sync_alert_status', '', 'success', 'N', 103, 1, NOW(), '已同步ERP值到CRM'), +(2056922815037460497, 4, '已忽略', 'IGNORED', 'crm_sync_alert_status', '', 'default', 'N', 103, 1, NOW(), '忽略此差异'); + +-- ======================================== +-- 5. 菜单配置 (sys_menu) +-- ======================================== +-- CRM同步管理菜单(放在CRM模块下) +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2045402164711710722, '同步管理', 0, 6, 'crm/sync', 'crm/sync/index', NULL, 1, 0, 'C', '0', '0', '', 'sync', 103, 1, NOW(), 'CRM-ERP客户同步管理'); + +-- 同步管理子菜单 +INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`) VALUES +(2045402164711710723, '同步配置', 2045402164711710722, 1, 'config', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:config', '#', 103, 1, NOW(), ''), +(2045402164711710724, '执行同步', 2045402164711710722, 2, 'execute', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:execute', '#', 103, 1, NOW(), ''), +(2045402164711710725, '同步日志', 2045402164711710722, 3, 'logs', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:log', '#', 103, 1, NOW(), ''), +(2045402164711710726, '预警管理', 2045402164711710722, 4, 'alerts', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:alert', '#', 103, 1, NOW(), ''), +(2045402164711710727, '公司映射', 2045402164711710722, 5, 'company', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:company', '#', 103, 1, NOW(), ''), +(2045402164711710728, '统计面板', 2045402164711710722, 6, 'stats', NULL, NULL, 1, 0, 'F', '0', '0', 'crm:sync:stats', '#', 103, 1, NOW(), ''); + +-- ======================================== +-- 完成 +-- ======================================== +-- 执行完成后,请检查表结构、字典数据和菜单配置是否正确创建 \ No newline at end of file