From aaaee184911e0bcde4a1bb8f805cca6d360b2210 Mon Sep 17 00:00:00 2001 From: tanglong <842690096@qq.com> Date: Tue, 21 Jul 2026 13:51:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/customer-report-wecom-callback.md | 2 +- .../config/FoundationProperties.java | 20 - .../report/CustomerDailyReportController.java | 23 ++ .../report/CustomerDailyReportRequest.java | 5 + .../report/CustomerDailyReportService.java | 374 ++++++++++++++++++ .../semantic/client/CrmReportClient.java | 47 --- .../CustomerDailyReportIntentHandler.java | 19 +- src/main/resources/application.yml | 8 +- 8 files changed, 417 insertions(+), 81 deletions(-) create mode 100644 src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportController.java create mode 100644 src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportRequest.java create mode 100644 src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportService.java delete mode 100644 src/main/java/com/company/agentfoundation/semantic/client/CrmReportClient.java diff --git a/docs/customer-report-wecom-callback.md b/docs/customer-report-wecom-callback.md index abdb825..1dd5735 100644 --- a/docs/customer-report-wecom-callback.md +++ b/docs/customer-report-wecom-callback.md @@ -15,6 +15,6 @@ GET 用于企业微信保存回调配置时验签,POST 用于接收用户消 - 沿用 `foundation.wecom` 下现有的 CorpId、Token、EncodingAESKey。 - 问答小助手和运动馆智能助手分别配置在 `foundation.wecom.apps.general`、`foundation.wecom.apps.sports-venue`。 - `DEEPSEEK_API_KEY`(沿用项目现有 DeepSeek 配置)。 -- `CRM_REPORT_BASE_URL`,指向学邦同步服务,例如 `http://127.0.0.1:9999`。 +- 客资日报接口运行在当前 8880 服务内,直接查询 `xuebang_db` 中的同步数据,不调用 9999 同步服务。 `/v1/integrations/wecom/callback` 保持原有扣子默认工作流;`/v1/integrations/wecom/sports-venue/callback` 使用 DeepSeek 语义识别并调用客资日报接口。回复消息时,服务端会根据入口选择对应应用的 AgentId 和 Secret。 diff --git a/src/main/java/com/company/agentfoundation/config/FoundationProperties.java b/src/main/java/com/company/agentfoundation/config/FoundationProperties.java index f42c3a7..2037a1d 100644 --- a/src/main/java/com/company/agentfoundation/config/FoundationProperties.java +++ b/src/main/java/com/company/agentfoundation/config/FoundationProperties.java @@ -13,7 +13,6 @@ public class FoundationProperties { private final Coze coze = new Coze(); private final Wecom wecom = new Wecom(); private final Semantic semantic = new Semantic(); - private final InternalApis internalApis = new InternalApis(); private final MiniProgram miniProgram = new MiniProgram(); public Auth getAuth() { return auth; } @@ -22,7 +21,6 @@ public class FoundationProperties { public Coze getCoze() { return coze; } public Wecom getWecom() { return wecom; } public Semantic getSemantic() { return semantic; } - public InternalApis getInternalApis() { return internalApis; } public MiniProgram getMiniProgram() { return miniProgram; } public static class Auth { @@ -148,24 +146,6 @@ public class FoundationProperties { public void setMinimumConfidence(double minimumConfidence) { this.minimumConfidence = minimumConfidence; } } - public static class InternalApis { - private final CrmReport crmReport = new CrmReport(); - public CrmReport getCrmReport() { return crmReport; } - - public static class CrmReport { - private String baseUrl = "http://127.0.0.1:9999"; - private String path = "/api/crm/daily-report"; - private int timeoutMs = 30000; - - public String getBaseUrl() { return baseUrl; } - public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } - public String getPath() { return path; } - public void setPath(String path) { this.path = path; } - public int getTimeoutMs() { return timeoutMs; } - public void setTimeoutMs(int timeoutMs) { this.timeoutMs = timeoutMs; } - } - } - public static class MiniProgram { private final Datasource datasource = new Datasource(); public Datasource getDatasource() { return datasource; } diff --git a/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportController.java b/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportController.java new file mode 100644 index 0000000..3c7621d --- /dev/null +++ b/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportController.java @@ -0,0 +1,23 @@ +package com.company.agentfoundation.crm.report; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/crm") +public class CustomerDailyReportController { + private final CustomerDailyReportService reportService; + + public CustomerDailyReportController(CustomerDailyReportService reportService) { + this.reportService = reportService; + } + + @PostMapping("/daily-report") + public Map dailyReport(@RequestBody CustomerDailyReportRequest request) { + return reportService.generate(request); + } +} diff --git a/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportRequest.java b/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportRequest.java new file mode 100644 index 0000000..f698a87 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportRequest.java @@ -0,0 +1,5 @@ +package com.company.agentfoundation.crm.report; + +import java.time.LocalDate; + +public record CustomerDailyReportRequest(LocalDate startDate, LocalDate endDate, String wecomUserId) {} diff --git a/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportService.java b/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportService.java new file mode 100644 index 0000000..bf08ec1 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportService.java @@ -0,0 +1,374 @@ +package com.company.agentfoundation.crm.report; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class CustomerDailyReportService { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter SQL_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final String CUSTOMER = "xuebang_db.xuebang_api_customer_findcustomerlist"; + private static final String FOLLOWUP = "xuebang_db.xuebang_api_customer_customerfollowup_findbyinstitution"; + private static final String APPOINTMENT = "xuebang_db.xuebang_api_customer_appiontmentmanage_findbyinstitution"; + private static final String CONTRACT = "xuebang_db.xuebang_api_contract_findbyinstitution"; + private static final String USER = "xuebang_db.xuebang_api_user_listbyinstitution"; + private static final String USER_COMPOSITE = "xuebang_db.xuebang_api_user_composite_search"; + private static final int WARNING_HOURS = 24; + private static final int CRITICAL_HOURS = 72; + private static final double LOW_CONVERSION = 0.35D; + private static final double COMPLETENESS_TARGET = 0.98D; + + private final NamedParameterJdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public CustomerDailyReportService(NamedParameterJdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + public Map generate(CustomerDailyReportRequest request) { + if (request == null || clean(request.wecomUserId()).isEmpty()) { + throw new IllegalArgumentException("wecomUserId is required"); + } + LocalDate today = LocalDate.now(BUSINESS_ZONE); + LocalDate startDate = request.startDate() == null ? today : request.startDate(); + LocalDate endDate = request.endDate() == null ? startDate : request.endDate(); + if (endDate.isBefore(startDate) || startDate.plusYears(1).isBefore(endDate)) { + throw new IllegalArgumentException("invalid report date range"); + } + + Caller caller = resolveCaller(request.wecomUserId()); + if (!caller.resolved()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权限查看:企业微信账号未完成人员映射"); + } + Scope scope = deriveScope(caller); + MapSqlParameterSource params = parameters(scope, startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay()); + + Map counts = jdbc.queryForMap(funnelSql(), params); + long added = number(counts, "new_count"); + long assigned = number(counts, "assigned_count"); + long firstCall = number(counts, "first_call_count"); + long appointment = number(counts, "appointment_count"); + long visit = number(counts, "visit_count"); + long deal = number(counts, "deal_count"); + long converted = number(counts, "converted_count"); + long invalid = number(counts, "invalid_count"); + + List> funnel = new ArrayList<>(); + stage(funnel, "NEW", "新增客资", added, null); + stage(funnel, "ASSIGNED", "分配", assigned, added); + stage(funnel, "FIRST_CALL", "首电", firstCall, assigned); + stage(funnel, "APPOINTMENT", "邀约", appointment, firstCall); + stage(funnel, "VISIT", "到店", visit, appointment); + stage(funnel, "DEAL", "成交", deal, visit); + + List> alerts = loadAlerts(params); + long noFollowup = alerts.stream().mapToLong(row -> ((Number) row.get("customerCount")).longValue()).sum(); + long over3Days = alerts.stream().mapToLong(row -> ((Number) row.get("over3DaysCount")).longValue()).sum(); + Map completeness = jdbc.queryForMap(completenessSql(), params); + long total = number(completeness, "total_count"); + long missingName = number(completeness, "missing_name"); + long missingContact = number(completeness, "missing_contact"); + long missingSource = number(completeness, "missing_source"); + long missingCampus = number(completeness, "missing_campus"); + long missingIntention = number(completeness, "missing_intention"); + double completenessRate = total == 0 ? 1D : round( + (total * 5D - missingName - missingContact - missingSource - missingCampus - missingIntention) / (total * 5D)); + + Map result = new LinkedHashMap<>(); + result.put("reportWindow", Map.of("startDate", startDate, "endDate", endDate, "zoneId", BUSINESS_ZONE.getId())); + result.put("scope", scopeMap(scope)); + result.put("caller", callerMap(caller)); + result.put("coreMetrics", Map.of( + "added", added, "assigned", assigned, "followed", firstCall, "firstCall", firstCall, + "converted", converted, "invalid", invalid)); + result.put("funnel", funnel); + result.put("exceptionMonitor", Map.of( + "noFollowupCount", noFollowup, "over24HoursCount", noFollowup, + "over3DaysCount", over3Days, "alerts", alerts)); + result.put("specificationCheck", Map.of( + "completenessRate", completenessRate, "targetRate", COMPLETENESS_TARGET, + "passed", completenessRate >= COMPLETENESS_TARGET, + "missingFields", Map.of("customerName", missingName, "contact", missingContact, + "source", missingSource, "campus", missingCampus, "intention", missingIntention))); + return result; + } + + private Caller resolveCaller(String wecomUserId) { + String safeUserId = clean(wecomUserId); + try { + return jdbc.queryForObject(""" + SELECT COALESCE(NULLIF(u.user_id, ''), c.boss_user_id, '') user_id, + COALESCE(NULLIF(u.name, ''), c.name, '') name, + COALESCE(u.organization_id, '') organization_id, + COALESCE(u.organization_name, '') organization_name, + COALESCE(u.role_names, '') role_names, + COALESCE(u.user_manage_organizations, '') manage_organizations + FROM %s c LEFT JOIN %s u ON u.user_id = c.boss_user_id + WHERE c.wxqy_user_id = :wecomUserId LIMIT 1 + """.formatted(USER_COMPOSITE, USER), Map.of("wecomUserId", safeUserId), + (rs, row) -> caller(safeUserId, rs.getString("user_id"), rs.getString("name"), + rs.getString("organization_id"), rs.getString("organization_name"), + rs.getString("role_names"), rs.getString("manage_organizations"), "WECOM_USER_ID", "")); + } catch (EmptyResultDataAccessException ignored) { + return resolveLegacy(safeUserId); + } + } + + private Caller resolveLegacy(String wecomUserId) { + Map legacy; + try { + legacy = jdbc.queryForMap(""" + SELECT employee_name, COALESCE(xuebang_account, '') xuebang_account, + COALESCE(data_scope_type, '') data_scope_type + FROM employee_directory WHERE wecom_user_id = :wecomUserId LIMIT 1 + """, Map.of("wecomUserId", wecomUserId)); + } catch (EmptyResultDataAccessException ignored) { + return Caller.unresolved(wecomUserId); + } + String account = text(legacy, "xuebang_account"); + if (!account.isEmpty()) { + try { + Map row = jdbc.queryForMap("SELECT * FROM " + USER + " WHERE account=:account LIMIT 1", + Map.of("account", account)); + return callerFromRow(wecomUserId, row, "LEGACY_ACCOUNT", text(legacy, "data_scope_type")); + } catch (EmptyResultDataAccessException ignored) { + // Continue with the unique-name fallback. + } + } + String normalized = normalizeName(text(legacy, "employee_name")); + List> matches = jdbc.queryForList(""" + SELECT user_id,name,organization_id,organization_name,role_names,user_manage_organizations + FROM %s WHERE COALESCE(has_dimission, '0')='0' + """.formatted(USER), Map.of()).stream() + .filter(row -> normalized.equals(normalizeName(text(row, "name")))) + .toList(); + return matches.size() == 1 + ? callerFromRow(wecomUserId, matches.get(0), "NORMALIZED_UNIQUE_NAME", text(legacy, "data_scope_type")) + : Caller.unresolved(wecomUserId); + } + + private Caller callerFromRow(String wecomUserId, Map row, String method, String legacyScope) { + return caller(wecomUserId, text(row, "user_id"), text(row, "name"), text(row, "organization_id"), + text(row, "organization_name"), text(row, "role_names"), text(row, "user_manage_organizations"), + method, legacyScope); + } + + private Caller caller(String wecomUserId, String userId, String name, String organizationId, + String organizationName, String roles, String managed, String method, String legacyScope) { + return new Caller(wecomUserId, !clean(userId).isEmpty(), clean(userId), clean(name), clean(organizationId), + clean(organizationName), clean(roles), clean(managed), method, clean(legacyScope)); + } + + private Scope deriveScope(Caller caller) { + if (caller.roles().contains("集团管理员") || "ALL_ORGANIZATION".equalsIgnoreCase(caller.legacyScope())) { + return new Scope("ALL_ORGANIZATION", true, false, List.of(), List.of(), caller.userId(), caller.name()); + } + Organizations managed = parseOrganizations(caller.managedOrganizations()); + boolean organizationRole = caller.roles().contains("城市总") || caller.roles().contains("馆长") + || caller.roles().contains("教学主管") || "MULTI_VENUE".equalsIgnoreCase(caller.legacyScope()) + || "VENUE".equalsIgnoreCase(caller.legacyScope()); + if (organizationRole || !managed.ids().isEmpty() || !managed.names().isEmpty()) { + Set ids = new LinkedHashSet<>(managed.ids()); + Set names = new LinkedHashSet<>(managed.names()); + if (!caller.organizationId().isEmpty()) ids.add(caller.organizationId()); + if (!caller.organizationName().isEmpty()) names.add(caller.organizationName()); + if (ids.isEmpty() && names.isEmpty()) throw forbidden(); + return new Scope(ids.size() + names.size() > 2 ? "MULTI_ORGANIZATION" : "ORGANIZATION", false, false, + List.copyOf(ids), List.copyOf(names), caller.userId(), caller.name()); + } + if (caller.organizationId().isEmpty() && caller.organizationName().isEmpty()) throw forbidden(); + return new Scope("SELF_OR_ASSIGNED", false, true, List.of(), List.of(), caller.userId(), caller.name()); + } + + private ResponseStatusException forbidden() { + return new ResponseStatusException(HttpStatus.FORBIDDEN, "无权限查看:账号不在可查询的组织范围内"); + } + + private Organizations parseOrganizations(String raw) { + if (raw == null || raw.isBlank()) return new Organizations(List.of(), List.of()); + try { + JsonNode root = objectMapper.readTree(raw); + Set ids = new LinkedHashSet<>(); + Set names = new LinkedHashSet<>(); + if (root.isArray()) root.forEach(node -> addOrganization(node, ids, names)); + else addOrganization(root, ids, names); + return new Organizations(List.copyOf(ids), List.copyOf(names)); + } catch (Exception ignored) { + return new Organizations(List.of(), List.of()); + } + } + + private void addOrganization(JsonNode node, Set ids, Set names) { + if (node == null || !node.isObject()) return; + addFirst(node, ids, "organizationId", "organization_id", "id"); + addFirst(node, names, "organizationName", "organization_name", "name"); + } + + private void addFirst(JsonNode node, Set target, String... fields) { + for (String field : fields) { + JsonNode value = node.get(field); + if (value != null && !value.asText().isBlank()) { + target.add(value.asText().trim()); + return; + } + } + } + + private List> loadAlerts(MapSqlParameterSource params) { + return jdbc.query(alertSql(), params, (rs, row) -> { + long count = rs.getLong("customer_count"); + long critical = rs.getLong("critical_count"); + String advisorName = rs.getString("advisor_name"); + Map alert = new LinkedHashMap<>(); + alert.put("advisorId", rs.getString("advisor_id")); + alert.put("advisorName", advisorName); + alert.put("customerCount", count); + alert.put("over3DaysCount", critical); + alert.put("maxStaleHours", rs.getLong("max_stale_hours")); + alert.put("level", critical > 0 ? "CRITICAL" : "WARNING"); + alert.put("message", "警告:顾问%s 名下 %d 个客资超过24小时未跟进,其中 %d 个已超过3天" + .formatted(advisorName, count, critical)); + return alert; + }); + } + + private MapSqlParameterSource parameters(Scope scope, LocalDateTime start, LocalDateTime end) { + return new MapSqlParameterSource() + .addValue("startTime", SQL_TIME.format(start)).addValue("endTime", SQL_TIME.format(end)) + .addValue("scopeAll", scope.all() ? 1 : 0).addValue("scopeAdvisor", scope.advisorOnly() ? 1 : 0) + .addValue("organizationIds", scope.organizationIds().isEmpty() ? List.of("__NONE__") : scope.organizationIds()) + .addValue("organizationNames", scope.organizationNames().isEmpty() ? List.of("__NONE__") : scope.organizationNames()) + .addValue("callerUserId", scope.advisorId()).addValue("callerUserName", scope.advisorName()) + .addValue("warningHours", WARNING_HOURS).addValue("criticalHours", CRITICAL_HOURS).addValue("maxAlerts", 100); + } + + private String scopeSql() { + return """ + (:scopeAll=1 OR (:scopeAdvisor=1 AND (c.deliver_user_id=:callerUserId OR c.deliver_user_name=:callerUserName + OR c.deliver_user_ids REGEXP CONCAT('(^|[^0-9A-Za-z])',:callerUserId,'([^0-9A-Za-z]|$)') + OR c.deliver_user_names LIKE CONCAT('%%',:callerUserName,'%%'))) + OR (:scopeAdvisor=0 AND (c.bl_school_id IN (:organizationIds) OR c.bl_school_name IN (:organizationNames)))) + """; + } + + private String funnelSql() { + return """ + WITH base AS (SELECT COALESCE(NULLIF(c.customer_id,''),c.data_key) customer_key,c.status,c.deal_status,c.deliver_time + FROM %s c WHERE c.create_time>=:startTime AND c.create_time<:endTime AND %s), + followed AS (SELECT DISTINCT customer_id FROM %s WHERE create_time<:endTime AND customer_id IS NOT NULL AND customer_id<>''), + appointed AS (SELECT DISTINCT customer_id FROM %s WHERE record_time<:endTime AND customer_id IS NOT NULL AND customer_id<>''), + visited AS (SELECT DISTINCT customer_id FROM %s WHERE appointment_confirm_time<:endTime + AND appointment_confirm IN ('PARENT_COME','STUDENT_COME','PARENT_STUDENT_COME') AND customer_id IS NOT NULL AND customer_id<>''), + contracted AS (SELECT DISTINCT customer_id FROM %s WHERE create_time<:endTime AND customer_id IS NOT NULL AND customer_id<>'' + AND COALESCE(contract_refund_status,'NORMAL') NOT IN ('REFUNDED','TRANSFER_LESSON')) + SELECT COUNT(DISTINCT b.customer_key) new_count, + COUNT(DISTINCT CASE WHEN b.deliver_time IS NOT NULL AND b.deliver_time<>'' THEN b.customer_key END) assigned_count, + COUNT(DISTINCT CASE WHEN b.deliver_time IS NOT NULL AND b.deliver_time<>'' AND f.customer_id IS NOT NULL THEN b.customer_key END) first_call_count, + COUNT(DISTINCT CASE WHEN f.customer_id IS NOT NULL AND a.customer_id IS NOT NULL THEN b.customer_key END) appointment_count, + COUNT(DISTINCT CASE WHEN a.customer_id IS NOT NULL AND v.customer_id IS NOT NULL THEN b.customer_key END) visit_count, + COUNT(DISTINCT CASE WHEN v.customer_id IS NOT NULL AND (b.deal_status='SIGNEUP' OR ct.customer_id IS NOT NULL) THEN b.customer_key END) deal_count, + COUNT(DISTINCT CASE WHEN b.deal_status='SIGNEUP' OR ct.customer_id IS NOT NULL THEN b.customer_key END) converted_count, + COUNT(DISTINCT CASE WHEN b.status='0' THEN b.customer_key END) invalid_count + FROM base b LEFT JOIN followed f ON f.customer_id=b.customer_key LEFT JOIN appointed a ON a.customer_id=b.customer_key + LEFT JOIN visited v ON v.customer_id=b.customer_key LEFT JOIN contracted ct ON ct.customer_id=b.customer_key + """.formatted(CUSTOMER, scopeSql(), FOLLOWUP, APPOINTMENT, APPOINTMENT, CONTRACT); + } + + private String alertSql() { + return """ + SELECT COALESCE(NULLIF(c.deliver_user_id,''),'UNASSIGNED') advisor_id, + COALESCE(NULLIF(c.deliver_user_name,''),NULLIF(c.deliver_user_names,''),'未指定顾问') advisor_name, + COUNT(DISTINCT COALESCE(NULLIF(c.customer_id,''),c.data_key)) customer_count, + SUM(CASE WHEN TIMESTAMPDIFF(HOUR,STR_TO_DATE(COALESCE(NULLIF(c.deliver_time,''),c.create_time),'%%Y-%%m-%%d %%H:%%i:%%s'), + STR_TO_DATE(:endTime,'%%Y-%%m-%%d %%H:%%i:%%s'))>=:criticalHours THEN 1 ELSE 0 END) critical_count, + MAX(TIMESTAMPDIFF(HOUR,STR_TO_DATE(COALESCE(NULLIF(c.deliver_time,''),c.create_time),'%%Y-%%m-%%d %%H:%%i:%%s'), + STR_TO_DATE(:endTime,'%%Y-%%m-%%d %%H:%%i:%%s'))) max_stale_hours + FROM %s c LEFT JOIN %s f ON f.customer_id=c.customer_id AND f.create_time<:endTime + WHERE c.create_time<:endTime AND c.status='1' AND c.deliver_time IS NOT NULL AND c.deliver_time<>'' AND f.customer_id IS NULL + AND TIMESTAMPDIFF(HOUR,STR_TO_DATE(COALESCE(NULLIF(c.deliver_time,''),c.create_time),'%%Y-%%m-%%d %%H:%%i:%%s'), + STR_TO_DATE(:endTime,'%%Y-%%m-%%d %%H:%%i:%%s'))>=:warningHours AND %s + GROUP BY advisor_id,advisor_name ORDER BY critical_count DESC,customer_count DESC,max_stale_hours DESC LIMIT :maxAlerts + """.formatted(CUSTOMER, FOLLOWUP, scopeSql()); + } + + private String completenessSql() { + return """ + SELECT COUNT(*) total_count, + SUM(CASE WHEN customer_name IS NULL OR TRIM(customer_name)='' THEN 1 ELSE 0 END) missing_name, + SUM(CASE WHEN contact IS NULL OR TRIM(contact)='' THEN 1 ELSE 0 END) missing_contact, + SUM(CASE WHEN (cus_org_name IS NULL OR TRIM(cus_org_name)='') AND (cus_type_name IS NULL OR TRIM(cus_type_name)='') THEN 1 ELSE 0 END) missing_source, + SUM(CASE WHEN bl_school_name IS NULL OR TRIM(bl_school_name)='' THEN 1 ELSE 0 END) missing_campus, + SUM(CASE WHEN intention_name IS NULL OR TRIM(intention_name)='' THEN 1 ELSE 0 END) missing_intention + FROM %s c WHERE c.create_time>=:startTime AND c.create_time<:endTime AND %s + """.formatted(CUSTOMER, scopeSql()); + } + + private void stage(List> stages, String key, String label, long count, Long previous) { + Double rate = previous == null ? null : previous == 0 ? 0D : round((double) count / previous); + Map stage = new LinkedHashMap<>(); + stage.put("key", key); stage.put("label", label); stage.put("count", count); + stage.put("conversionRate", rate); stage.put("lowConversion", rate != null && previous > 0 && rate < LOW_CONVERSION); + stages.add(stage); + } + + private Map scopeMap(Scope scope) { + Map map = new LinkedHashMap<>(); + map.put("type", scope.type()); map.put("organizationIds", scope.organizationIds()); + map.put("organizationNames", scope.organizationNames()); map.put("advisorId", scope.advisorId()); + map.put("advisorName", scope.advisorName()); return map; + } + + private Map callerMap(Caller caller) { + Map map = new LinkedHashMap<>(); + map.put("wecomUserId", caller.wecomUserId()); map.put("mappingResolved", caller.resolved()); + map.put("xuebangUserId", caller.userId()); map.put("xuebangUserName", caller.name()); + map.put("xuebangOrganizationId", caller.organizationId()); map.put("xuebangOrganizationName", caller.organizationName()); + map.put("xuebangRoleNames", caller.roles()); map.put("matchMethod", caller.matchMethod()); return map; + } + + private static long number(Map row, String key) { + Object value = row.get(key); return value instanceof Number number ? number.longValue() : 0L; + } + private static String text(Map row, String key) { + Object value = row.get(key); return value == null ? "" : value.toString().trim(); + } + private static String clean(String value) { return value == null ? "" : value.trim(); } + private static String normalizeName(String value) { + return clean(value).replaceFirst("(?i)^(SH|BJ|HF|GZ|CD|XZ)[\\s-]*", "") + .replace("跃动体育", "").replace("跃动运动馆", "") + .replaceAll("[\\s_,·.()()\\-]", "").toLowerCase(Locale.ROOT); + } + private static double round(double value) { + return BigDecimal.valueOf(value).setScale(4, RoundingMode.HALF_UP).doubleValue(); + } + + private record Caller(String wecomUserId, boolean resolved, String userId, String name, String organizationId, + String organizationName, String roles, String managedOrganizations, String matchMethod, + String legacyScope) { + static Caller unresolved(String userId) { return new Caller(userId, false, "", "", "", "", "", "", "UNMATCHED", ""); } + } + private record Scope(String type, boolean all, boolean advisorOnly, List organizationIds, + List organizationNames, String advisorId, String advisorName) {} + private record Organizations(List ids, List names) {} +} diff --git a/src/main/java/com/company/agentfoundation/semantic/client/CrmReportClient.java b/src/main/java/com/company/agentfoundation/semantic/client/CrmReportClient.java deleted file mode 100644 index 205ca65..0000000 --- a/src/main/java/com/company/agentfoundation/semantic/client/CrmReportClient.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.company.agentfoundation.semantic.client; - -import com.company.agentfoundation.config.FoundationProperties; -import com.fasterxml.jackson.databind.JsonNode; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; - -import java.time.Duration; -import java.time.LocalDate; - -@Component -public class CrmReportClient { - private final FoundationProperties.InternalApis.CrmReport properties; - private final WebClient webClient; - - public CrmReportClient(FoundationProperties foundationProperties, WebClient.Builder builder) { - this.properties = foundationProperties.getInternalApis().getCrmReport(); - this.webClient = builder.baseUrl(trimTrailingSlash(properties.getBaseUrl())).build(); - } - - public JsonNode dailyReport(LocalDate startDate, LocalDate endDate, String wecomUserId) { - return webClient.post() - .uri(properties.getPath()) - .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(new DailyReportRequest(startDate, endDate, wecomUserId)) - .retrieve() - .bodyToMono(JsonNode.class) - .block(Duration.ofMillis(Math.max(1000, properties.getTimeoutMs()))); - } - - public boolean isForbidden(Throwable throwable) { - return throwable instanceof WebClientResponseException.Forbidden; - } - - private String trimTrailingSlash(String value) { - if (value == null || value.isBlank()) { - return "http://127.0.0.1:9999"; - } - return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; - } - - private record DailyReportRequest(LocalDate startDate, LocalDate endDate, String wecomUserId) {} -} diff --git a/src/main/java/com/company/agentfoundation/semantic/handler/CustomerDailyReportIntentHandler.java b/src/main/java/com/company/agentfoundation/semantic/handler/CustomerDailyReportIntentHandler.java index 10ea824..7f6edd0 100644 --- a/src/main/java/com/company/agentfoundation/semantic/handler/CustomerDailyReportIntentHandler.java +++ b/src/main/java/com/company/agentfoundation/semantic/handler/CustomerDailyReportIntentHandler.java @@ -1,11 +1,13 @@ package com.company.agentfoundation.semantic.handler; +import com.company.agentfoundation.crm.report.CustomerDailyReportRequest; +import com.company.agentfoundation.crm.report.CustomerDailyReportService; import com.company.agentfoundation.semantic.SemanticDateRangeResolver; import com.company.agentfoundation.semantic.SemanticIntentHandler; import com.company.agentfoundation.semantic.SemanticRecognition; import com.company.agentfoundation.semantic.SemanticRequestContext; -import com.company.agentfoundation.semantic.client.CrmReportClient; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -19,14 +21,17 @@ public class CustomerDailyReportIntentHandler implements SemanticIntentHandler { private static final Logger log = LoggerFactory.getLogger(CustomerDailyReportIntentHandler.class); private final SemanticDateRangeResolver dateRangeResolver; - private final CrmReportClient reportClient; + private final CustomerDailyReportService reportService; + private final ObjectMapper objectMapper; public CustomerDailyReportIntentHandler( SemanticDateRangeResolver dateRangeResolver, - CrmReportClient reportClient + CustomerDailyReportService reportService, + ObjectMapper objectMapper ) { this.dateRangeResolver = dateRangeResolver; - this.reportClient = reportClient; + this.reportService = reportService; + this.objectMapper = objectMapper; } @Override @@ -49,13 +54,15 @@ public class CustomerDailyReportIntentHandler implements SemanticIntentHandler { SemanticDateRangeResolver.DateRange range = dateRangeResolver.resolve( context.userText(), recognition.parameters(), context.requestTime().toLocalDate()); try { - JsonNode report = reportClient.dailyReport(range.startDate(), range.endDate(), context.wecomUserId()); + JsonNode report = objectMapper.valueToTree(reportService.generate( + new CustomerDailyReportRequest(range.startDate(), range.endDate(), context.wecomUserId()))); if (report == null || report.isNull()) { return "客资日报服务暂未返回数据,请稍后重试。"; } return format(report, range); } catch (Exception e) { - if (reportClient.isForbidden(e)) { + if (e instanceof org.springframework.web.server.ResponseStatusException status + && status.getStatusCode().value() == 403) { return "无权限查看:你的企业微信账号不在可查询的组织范围内。"; } log.error("customer daily report intent failed wecomUserId={} range={}~{} err={}", diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2c1ce6b..7102493 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,7 +38,7 @@ foundation: coze: base-url: https://api.coze.cn chat-path: /v1/workflows/chat - api-key: ${COZE_API_KEY:cztei_qKcNWz5IvhFXAkcxTNFOE7Tnaaua8GJ7VyOREi99l9nGvR7uGjbpqkf3ZmCrYu94T} + api-key: ${COZE_API_KEY:cztei_q6SLPbgARC2LugTaJNRUEsm8LhH4aFpWEy1C2vEyMIEiuSfpUnRS2cVSFAqjmkUtE} default-workflow: wecom-default workflows: wecom-default: @@ -67,12 +67,6 @@ foundation: semantic: agent-id: crm-assistant minimum-confidence: 0.60 - internal-apis: - crm-report: - base-url: ${CRM_REPORT_BASE_URL:http://127.0.0.1:9999} - path: /api/crm/daily-report - timeout-ms: 30000 - # wecom: # enabled: true # corp-id: ww11a329ca8ee0c40e