From dafa464240a5449b7c7ccdba4e465d3fb9b5a0d4 Mon Sep 17 00:00:00 2001 From: tanglong <842690096@qq.com> Date: Tue, 21 Jul 2026 16:53:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=A6=E5=91=98=E6=95=99=E5=AD=A6=E5=87=BA?= =?UTF-8?q?=E5=8B=A4=E6=97=A5=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../semantic/SemanticRecognitionService.java | 7 + ...TeachingAttendanceReportIntentHandler.java | 136 ++++++ ...entTeachingAttendanceReportController.java | 23 + ...tudentTeachingAttendanceReportRequest.java | 9 + ...tudentTeachingAttendanceReportService.java | 439 ++++++++++++++++++ .../SemanticRecognitionServiceTest.java | 9 + 6 files changed, 623 insertions(+) create mode 100644 src/main/java/com/company/agentfoundation/semantic/handler/StudentTeachingAttendanceReportIntentHandler.java create mode 100644 src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportController.java create mode 100644 src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportRequest.java create mode 100644 src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportService.java diff --git a/src/main/java/com/company/agentfoundation/semantic/SemanticRecognitionService.java b/src/main/java/com/company/agentfoundation/semantic/SemanticRecognitionService.java index cdaa572..4c8d418 100644 --- a/src/main/java/com/company/agentfoundation/semantic/SemanticRecognitionService.java +++ b/src/main/java/com/company/agentfoundation/semantic/SemanticRecognitionService.java @@ -85,6 +85,13 @@ public class SemanticRecognitionService { private SemanticRecognition fallback(String userText) { String text = userText == null ? "" : userText; + boolean teachingAttendanceReport = text.contains("教学出勤") || text.contains("学员出勤") + || text.contains("出勤日报") || text.contains("排课率") || text.contains("高频缺勤") + || (text.contains("剩余课时") && (text.contains("日报") || text.contains("预警"))); + if (teachingAttendanceReport) { + return new SemanticRecognition("STUDENT_TEACHING_ATTENDANCE_DAILY_REPORT", 0.92D, + Map.of(), "KEYWORD_FALLBACK"); + } boolean operationsReport = text.contains("客户运营") || text.contains("私域") || (text.contains("企业微信") && (text.contains("客户日报") || text.contains("运营日报"))); if (operationsReport && (text.contains("日报") || text.contains("报表") || text.contains("运营"))) { diff --git a/src/main/java/com/company/agentfoundation/semantic/handler/StudentTeachingAttendanceReportIntentHandler.java b/src/main/java/com/company/agentfoundation/semantic/handler/StudentTeachingAttendanceReportIntentHandler.java new file mode 100644 index 0000000..4eb2206 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/semantic/handler/StudentTeachingAttendanceReportIntentHandler.java @@ -0,0 +1,136 @@ +package com.company.agentfoundation.semantic.handler; + +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.teaching.report.StudentTeachingAttendanceReportRequest; +import com.company.agentfoundation.teaching.report.StudentTeachingAttendanceReportService; +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; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Locale; + +@Component +public class StudentTeachingAttendanceReportIntentHandler implements SemanticIntentHandler { + private static final Logger log = LoggerFactory.getLogger(StudentTeachingAttendanceReportIntentHandler.class); + + private final SemanticDateRangeResolver dateRangeResolver; + private final StudentTeachingAttendanceReportService reportService; + private final ObjectMapper objectMapper; + + public StudentTeachingAttendanceReportIntentHandler( + SemanticDateRangeResolver dateRangeResolver, + StudentTeachingAttendanceReportService reportService, + ObjectMapper objectMapper + ) { + this.dateRangeResolver = dateRangeResolver; + this.reportService = reportService; + this.objectMapper = objectMapper; + } + + @Override + public String intent() { + return "STUDENT_TEACHING_ATTENDANCE_DAILY_REPORT"; + } + + @Override + public String description() { + return "查询学员教学出勤日报、学员量、排课率、出勤率、剩余课时预警或高频缺勤预警"; + } + + @Override + public String example() { + return "查看今日学员教学出勤日报"; + } + + @Override + public String handle(SemanticRequestContext context, SemanticRecognition recognition) { + SemanticDateRangeResolver.DateRange range = dateRangeResolver.resolve(context, recognition); + try { + JsonNode report = objectMapper.valueToTree(reportService.generate( + new StudentTeachingAttendanceReportRequest(range.startDate(), range.endDate(), context.wecomUserId()))); + return format(report, range); + } catch (ResponseStatusException exception) { + if (exception.getStatusCode().value() == 403) { + return "无权限查看:仅集团管理员可查看全部组织的学员教学出勤日报。UserID:" + context.wecomUserId(); + } + throw exception; + } catch (Exception exception) { + log.error("student teaching attendance report failed wecomUserId={} range={}~{} err={}", + context.wecomUserId(), range.startDate(), range.endDate(), exception.getMessage(), exception); + return "学员教学出勤日报查询失败,请查看服务器日志。"; + } + } + + private String format(JsonNode report, SemanticDateRangeResolver.DateRange range) { + JsonNode metrics = report.path("coreMetrics"); + JsonNode lowHours = report.path("remainingHoursWarning"); + JsonNode absence = report.path("frequentAbsenceWarning"); + JsonNode delivery = report.path("alertDelivery"); + + StringBuilder reply = new StringBuilder("### 🎓 学员教学出勤日报\n> 日期:") + .append(range.startDate()); + if (!range.startDate().equals(range.endDate())) { + reply.append(" 至 ").append(range.endDate()); + } + reply.append(" | 范围:**全部组织**") + .append("\n\n**交付概览** 学员量:") + .append(metrics.path("studentCount").asLong()).append("") + .append(" | 排课学员:").append(metrics.path("scheduledStudentCount").asLong()) + .append(" | 排课率:").append(percent(metrics.path("schedulingRate").asDouble())) + .append("\n\n**出勤质量** 排课:").append(metrics.path("scheduleCount").asLong()) + .append(" | 出勤:").append(metrics.path("attendanceCount").asLong()) + .append(" | 缺勤:").append(metrics.path("absenceCount").asLong()) + .append(" | 出勤率:") + .append(percent(metrics.path("attendanceRate").asDouble())).append("") + .append("\n\n**课时预警** 低于10课时:") + .append(lowHours.path("studentCount").asInt()).append("人") + .append(" | 剩余课时合计:").append(number(lowHours.path("remainingHoursTotal").asDouble())) + .append("\n\n**高频缺勤** 排课≥3次且缺勤≥2次:") + .append(absence.path("studentCount").asInt()).append("人") + .append("\n\n**预警推送** 已匹配教练/顾问/馆长:") + .append(delivery.path("resolvedRecipientCount").asInt()).append("人"); + + appendWarningStudents(reply, "低课时", lowHours.path("students"), true); + appendWarningStudents(reply, "高频缺勤", absence.path("students"), false); + if (!delivery.path("readyForAutomaticPush").asBoolean(false) + && (lowHours.path("studentCount").asInt() > 0 || absence.path("studentCount").asInt() > 0)) { + reply.append("\n\n部分预警未匹配企业微信 UserID,请补全人员映射。"); + } + return reply.toString(); + } + + private void appendWarningStudents(StringBuilder reply, String title, JsonNode students, boolean lowHours) { + if (!students.isArray() || students.isEmpty()) return; + reply.append("\n\n**").append(title).append("名单(前5)**"); + int limit = Math.min(5, students.size()); + for (int index = 0; index < limit; index++) { + JsonNode student = students.get(index); + reply.append("\n>").append(index + 1).append(". ") + .append(student.path("studentName").asText("未知学员")) + .append("(").append(student.path("campusName").asText("未知校区")).append(")"); + if (lowHours) { + reply.append(" 剩余").append(number(student.path("remainingHours").asDouble())).append("课时"); + } else { + reply.append(" 排课").append(student.path("scheduleCount").asInt()).append("次") + .append("/缺勤").append(student.path("absenceCount").asInt()).append("次"); + } + } + } + + private String percent(double rate) { + return number(rate * 100D) + "%"; + } + + private String number(double value) { + if (Math.abs(value - Math.rint(value)) < 0.00001D) { + return String.format(Locale.ROOT, "%.0f", value); + } + return String.format(Locale.ROOT, "%.1f", value); + } +} diff --git a/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportController.java b/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportController.java new file mode 100644 index 0000000..bd5edd3 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportController.java @@ -0,0 +1,23 @@ +package com.company.agentfoundation.teaching.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/teaching/student-attendance") +public class StudentTeachingAttendanceReportController { + private final StudentTeachingAttendanceReportService reportService; + + public StudentTeachingAttendanceReportController(StudentTeachingAttendanceReportService reportService) { + this.reportService = reportService; + } + + @PostMapping("/daily-report") + public Map dailyReport(@RequestBody StudentTeachingAttendanceReportRequest request) { + return reportService.generate(request); + } +} diff --git a/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportRequest.java b/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportRequest.java new file mode 100644 index 0000000..8513685 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportRequest.java @@ -0,0 +1,9 @@ +package com.company.agentfoundation.teaching.report; + +import java.time.LocalDate; + +public record StudentTeachingAttendanceReportRequest( + LocalDate startDate, + LocalDate endDate, + String wecomUserId +) {} diff --git a/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportService.java b/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportService.java new file mode 100644 index 0000000..ab75f03 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportService.java @@ -0,0 +1,439 @@ +package com.company.agentfoundation.teaching.report; + +import com.company.agentfoundation.crm.report.CustomerDailyReportService; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +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 StudentTeachingAttendanceReportService { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final BigDecimal LOW_REMAINING_HOURS = BigDecimal.TEN; + private static final int FREQUENT_SCHEDULE_COUNT = 3; + private static final int FREQUENT_ABSENCE_COUNT = 2; + + private static final String STUDENT = "xuebang_db.xuebang_api_student_findstudentsbyinstitution"; + private static final String MINI_COURSE = "xuebang_db.xuebang_api_miniclass_course_page"; + private static final String MINI_ATTENDANCE = "xuebang_db.xuebang_api_miniclass_attendance_page"; + private static final String ONE_ON_ONE = "xuebang_db.xuebang_api_oneonone_course_findpagebyinstitution"; + private static final String ONE_ON_N_COURSE = "xuebang_db.xuebang_api_oneonn_course_findbyinstitution"; + private static final String ONE_ON_N_ATTENDANCE = "xuebang_db.xuebang_api_oneonn_attendance_findbyinstitution"; + private static final String CONTRACT = "xuebang_db.xuebang_api_contract_findbyinstitution"; + private static final String CONTRACT_PRODUCT = "xuebang_db.xuebang_api_contractproduct_findbyinstitution"; + private static final String USER = "xuebang_db.xuebang_api_user_listbyinstitution"; + + private final NamedParameterJdbcTemplate jdbc; + private final CustomerDailyReportService accessService; + + public StudentTeachingAttendanceReportService( + NamedParameterJdbcTemplate jdbc, + CustomerDailyReportService accessService + ) { + this.jdbc = jdbc; + this.accessService = accessService; + } + + public Map generate(StudentTeachingAttendanceReportRequest 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"); + } + + accessService.assertCanViewAllOrganizations(request.wecomUserId()); + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("startDate", startDate.toString()) + .addValue("endExclusive", endDate.plusDays(1).toString()) + .addValue("lowHours", LOW_REMAINING_HOURS); + + long studentCount = jdbc.queryForObject(activeStudentCountSql(), params, Long.class); + ScheduleResult schedule = loadScheduleResult(params); + long scheduledStudentCount = schedule.scheduledStudentCount(); + long scheduleCount = schedule.scheduleCount(); + long evaluatedCount = schedule.evaluatedCount(); + long attendanceCount = schedule.attendanceCount(); + long absenceCount = schedule.absenceCount(); + + LowHoursResult lowHours = loadLowHoursAlerts(params); + List> lowHoursAlerts = lowHours.alerts(); + List> absenceAlerts = schedule.frequentAbsenceAlerts(); + RecipientDirectory recipientDirectory = loadRecipientDirectory(); + enrichRecipients(lowHoursAlerts, recipientDirectory); + enrichRecipients(absenceAlerts, recipientDirectory); + + Set pushUserIds = new LinkedHashSet<>(); + lowHoursAlerts.forEach(row -> collectPushUserIds(row, pushUserIds)); + absenceAlerts.forEach(row -> collectPushUserIds(row, pushUserIds)); + + Map result = new LinkedHashMap<>(); + result.put("reportWindow", Map.of( + "startDate", startDate, "endDate", endDate, "zoneId", BUSINESS_ZONE.getId())); + result.put("scope", Map.of("type", "ALL_ORGANIZATION", "label", "全部组织")); + result.put("coreMetrics", Map.of( + "studentCount", studentCount, + "scheduledStudentCount", scheduledStudentCount, + "schedulingRate", rate(scheduledStudentCount, studentCount), + "scheduleCount", scheduleCount, + "evaluatedScheduleCount", evaluatedCount, + "attendanceCount", attendanceCount, + "absenceCount", absenceCount, + "attendanceRate", rate(attendanceCount, evaluatedCount))); + result.put("remainingHoursWarning", Map.of( + "thresholdExclusive", LOW_REMAINING_HOURS, + "studentCount", lowHours.totalCount(), + "returnedStudentCount", lowHoursAlerts.size(), + "truncated", lowHours.totalCount() > lowHoursAlerts.size(), + "remainingHoursTotal", lowHours.totalRemainingHours(), + "students", lowHoursAlerts)); + result.put("frequentAbsenceWarning", Map.of( + "scheduleThresholdInclusive", FREQUENT_SCHEDULE_COUNT, + "absenceThresholdInclusive", FREQUENT_ABSENCE_COUNT, + "studentCount", schedule.frequentAbsenceCount(), + "returnedStudentCount", absenceAlerts.size(), + "truncated", schedule.frequentAbsenceCount() > absenceAlerts.size(), + "students", absenceAlerts)); + result.put("alertDelivery", Map.of( + "targetRoles", List.of("COACH", "ADVISOR", "VENUE_MANAGER"), + "resolvedRecipientCount", pushUserIds.size(), + "resolvedWecomUserIds", List.copyOf(pushUserIds), + "readyForAutomaticPush", !pushUserIds.isEmpty(), + "note", "日报已生成可推送对象;定时推送需配置执行时间后启用")); + return result; + } + + private LowHoursResult loadLowHoursAlerts(MapSqlParameterSource params) { + List> alerts = jdbc.query(lowHoursSql(), params, (rs, rowNum) -> { + Map row = new LinkedHashMap<>(); + row.put("studentId", clean(rs.getString("student_id"))); + row.put("studentName", clean(rs.getString("student_name"))); + row.put("campusId", clean(rs.getString("campus_id"))); + row.put("campusName", clean(rs.getString("campus_name"))); + row.put("remainingHours", rs.getBigDecimal("remaining_hours")); + row.put("advisorId", clean(rs.getString("advisor_id"))); + row.put("advisorName", clean(rs.getString("advisor_name"))); + row.put("coachId", ""); + row.put("coachName", ""); + row.put("warningType", "LOW_REMAINING_HOURS"); + row.put("totalAlertCount", rs.getLong("total_alert_count")); + row.put("totalRemainingHours", rs.getBigDecimal("total_remaining_hours")); + return row; + }); + long totalCount = alerts.isEmpty() ? 0 : number(alerts.get(0), "totalAlertCount"); + BigDecimal totalHours = alerts.isEmpty() ? BigDecimal.ZERO : decimal(alerts.get(0).get("totalRemainingHours")); + alerts.forEach(row -> { + row.remove("totalAlertCount"); + row.remove("totalRemainingHours"); + }); + return new LowHoursResult(totalCount, totalHours, alerts); + } + + private ScheduleResult loadScheduleResult(MapSqlParameterSource params) { + Map students = new LinkedHashMap<>(); + long[] totals = new long[4]; + jdbc.query(scheduleDetailSql(), params, rs -> { + totals[0]++; + totals[1] += rs.getLong("evaluated"); + totals[2] += rs.getLong("attended"); + totals[3] += rs.getLong("absent"); + String studentId = clean(rs.getString("student_id")); + String studentName = clean(rs.getString("student_name")); + String campusId = clean(rs.getString("campus_id")); + String campusName = clean(rs.getString("campus_name")); + StudentSchedule student = students.computeIfAbsent(studentId, ignored -> new StudentSchedule( + studentId, studentName, campusId, campusName)); + student.scheduleCount++; + student.absenceCount += rs.getLong("absent"); + student.addCoach(clean(rs.getString("coach_id")), clean(rs.getString("coach_name"))); + student.addAdvisor(clean(rs.getString("advisor_id")), clean(rs.getString("advisor_name"))); + }); + List> alerts = students.values().stream() + .filter(student -> student.scheduleCount >= FREQUENT_SCHEDULE_COUNT + && student.absenceCount >= FREQUENT_ABSENCE_COUNT) + .sorted((first, second) -> { + int absenceComparison = Long.compare(second.absenceCount, first.absenceCount); + return absenceComparison != 0 ? absenceComparison : Long.compare(second.scheduleCount, first.scheduleCount); + }) + .map(StudentSchedule::toAlert) + .limit(100) + .toList(); + long alertCount = students.values().stream().filter(student -> + student.scheduleCount >= FREQUENT_SCHEDULE_COUNT + && student.absenceCount >= FREQUENT_ABSENCE_COUNT).count(); + return new ScheduleResult(students.size(), totals[0], totals[1], totals[2], totals[3], alertCount, alerts); + } + + private void enrichRecipients(List> alerts, RecipientDirectory directory) { + for (Map alert : alerts) { + List> recipients = new ArrayList<>(); + addRecipient(recipients, "COACH", text(alert, "coachId"), text(alert, "coachName"), directory); + addRecipient(recipients, "ADVISOR", text(alert, "advisorId"), text(alert, "advisorName"), directory); + for (Recipient manager : directory.managersFor(text(alert, "campusName"))) { + addRecipient(recipients, manager.role(), manager.name(), manager.wecomUserId()); + } + alert.put("recipients", recipients); + alert.put("pushReady", recipients.stream().anyMatch(item -> !text(item, "wecomUserId").isEmpty())); + } + } + + private RecipientDirectory loadRecipientDirectory() { + Map users = new LinkedHashMap<>(); + jdbc.queryForList(""" + SELECT u.user_id, e.employee_name, e.wecom_user_id, e.primary_role_code, + COALESCE(e.xuebang_primary_org, '') xuebang_primary_org, + COALESCE(e.xuebang_manage_scope, '') xuebang_manage_scope + FROM %s u + JOIN employee_directory e ON LOWER(e.xuebang_account) = LOWER(u.account) + WHERE COALESCE(e.wecom_user_id, '') <> '' + """.formatted(USER), Map.of()).forEach(row -> users.put(clean(value(row, "user_id")), new Recipient( + clean(value(row, "primary_role_code")), clean(value(row, "employee_name")), + clean(value(row, "wecom_user_id")), clean(value(row, "xuebang_primary_org")), + clean(value(row, "xuebang_manage_scope"))))); + + List managers = jdbc.queryForList(""" + SELECT employee_name, wecom_user_id, primary_role_code, + COALESCE(xuebang_primary_org, '') xuebang_primary_org, + COALESCE(xuebang_manage_scope, '') xuebang_manage_scope + FROM employee_directory + WHERE primary_role_code IN ('VENUE_MANAGER', 'VENUE_OWNER') + AND COALESCE(wecom_user_id, '') <> '' + """, Map.of()).stream().map(row -> new Recipient( + clean(value(row, "primary_role_code")), clean(value(row, "employee_name")), + clean(value(row, "wecom_user_id")), clean(value(row, "xuebang_primary_org")), + clean(value(row, "xuebang_manage_scope")))).toList(); + return new RecipientDirectory(users, managers); + } + + private void addRecipient( + List> recipients, + String role, + String xuebangUserId, + String fallbackName, + RecipientDirectory directory + ) { + Recipient mapped = directory.users().get(clean(xuebangUserId)); + if (mapped != null) { + addRecipient(recipients, role, mapped.name(), mapped.wecomUserId()); + } else if (!clean(fallbackName).isEmpty()) { + addRecipient(recipients, role, fallbackName, ""); + } + } + + private void addRecipient(List> recipients, String role, String name, String wecomUserId) { + boolean duplicate = recipients.stream().anyMatch(item -> + role.equals(text(item, "role")) && clean(wecomUserId).equalsIgnoreCase(text(item, "wecomUserId"))); + if (!duplicate) { + Map recipient = new LinkedHashMap<>(); + recipient.put("role", role); + recipient.put("name", clean(name)); + recipient.put("wecomUserId", clean(wecomUserId)); + recipients.add(recipient); + } + } + + @SuppressWarnings("unchecked") + private void collectPushUserIds(Map row, Set target) { + Object value = row.get("recipients"); + if (!(value instanceof List recipients)) return; + for (Object item : recipients) { + if (item instanceof Map recipient) { + Object rawUserId = recipient.get("wecomUserId"); + String userId = clean(rawUserId == null ? "" : String.valueOf(rawUserId)); + if (!userId.isEmpty()) target.add(userId); + } + } + } + + private String activeStudentCountSql() { + return """ + SELECT COUNT(DISTINCT student_id) + FROM %s + WHERE stu_status = '1' AND student_contract_type = 'OFFICIAL_STUDENT' + """.formatted(STUDENT); + } + + private String scheduleDetailSql() { + return """ + SELECT a.student_id, a.student_name, c.bl_campus_id campus_id, c.bl_campus_name campus_name, + c.teacher_id coach_id, c.teacher_name coach_name, + c.study_manager_id advisor_id, c.study_manager_name advisor_name, + CASE WHEN UPPER(COALESCE(a.attendent_main_status, '')) = 'ATTENDANCE' THEN 1 ELSE 0 END attended, + CASE WHEN UPPER(COALESCE(a.attendent_main_status, '')) = 'ABSENCE' THEN 1 ELSE 0 END absent, + CASE WHEN UPPER(COALESCE(a.attendent_main_status, '')) IN ('ATTENDANCE', 'ABSENCE') THEN 1 ELSE 0 END evaluated + FROM %s c JOIN %s a ON a.mini_class_course_id = c.mini_class_course_id + WHERE c.course_date >= :startDate AND c.course_date < :endExclusive + UNION ALL + SELECT o.student_id, o.student_name, o.bl_campus_id, o.bl_campus_name, + o.teacher_id, o.teacher_name, o.study_manager_id, o.study_manager_name, + CASE WHEN UPPER(COALESCE(o.course_status, '')) = 'CHARGED' THEN 1 ELSE 0 END, + CASE WHEN UPPER(COALESCE(o.course_status, '')) IN ('ABSENCE', 'LEAVE') THEN 1 ELSE 0 END, + CASE WHEN UPPER(COALESCE(o.course_status, '')) IN ('CHARGED', 'ABSENCE', 'LEAVE') THEN 1 ELSE 0 END + FROM %s o + WHERE o.course_date >= :startDate AND o.course_date < :endExclusive + UNION ALL + SELECT a.student_id, a.student_name, c.bl_campus_id, c.bl_campus_name, + c.teacher_id, c.teacher_name, c.head_teacher_id, c.head_teacher_name, + CASE WHEN UPPER(COALESCE(a.status, '')) = 'COMPLETE' THEN 1 ELSE 0 END, + CASE WHEN UPPER(COALESCE(a.status, '')) = 'LEAVE' THEN 1 ELSE 0 END, + CASE WHEN UPPER(COALESCE(a.status, '')) IN ('COMPLETE', 'LEAVE') THEN 1 ELSE 0 END + FROM %s c JOIN %s a ON a.one_on_ncourse_id = c.source_id + WHERE c.course_date >= :startDate AND c.course_date < :endExclusive + """.formatted(MINI_COURSE, MINI_ATTENDANCE, ONE_ON_ONE, ONE_ON_N_COURSE, ONE_ON_N_ATTENDANCE); + } + + private String lowHoursSql() { + return """ + SELECT balance.*, COUNT(*) OVER() total_alert_count, + SUM(balance.remaining_hours) OVER() total_remaining_hours + FROM ( + SELECT c.student_id, MAX(c.student_name) student_name, + MAX(c.bl_campus_id) campus_id, MAX(c.bl_campus_name) campus_name, + COALESCE(NULLIF(MAX(s.study_maneger_id), ''), MAX(c.sign_staff_id), '') advisor_id, + COALESCE(NULLIF(MAX(s.study_maneger_name), ''), MAX(c.sign_staff_name), '') advisor_name, + ROUND(SUM( + CAST(COALESCE(NULLIF(cp.real_hour, ''), '0') AS DECIMAL(18,2)) + + CAST(COALESCE(NULLIF(cp.promotion_hour, ''), '0') AS DECIMAL(18,2)) + - CAST(COALESCE(NULLIF(cp.consume_real_hour, ''), '0') AS DECIMAL(18,2)) + - CAST(COALESCE(NULLIF(cp.consume_promotion_hour, ''), '0') AS DECIMAL(18,2)) + - CAST(COALESCE(NULLIF(cp.refund_real_hour, ''), '0') AS DECIMAL(18,2)) + - CAST(COALESCE(NULLIF(cp.refund_promotion_hour, ''), '0') AS DECIMAL(18,2)) + ), 2) remaining_hours + FROM %s cp + JOIN %s c ON c.source_id = cp.contract_id + JOIN %s s ON s.student_id = c.student_id + WHERE UPPER(COALESCE(cp.status, '')) = 'NORMAL' + AND s.stu_status = '1' AND s.student_contract_type = 'OFFICIAL_STUDENT' + GROUP BY c.student_id + ) balance + WHERE balance.remaining_hours >= 0 AND balance.remaining_hours < :lowHours + ORDER BY remaining_hours ASC, student_name + LIMIT 100 + """.formatted(CONTRACT_PRODUCT, CONTRACT, STUDENT); + } + + private double rate(long numerator, long denominator) { + if (denominator == 0) return 0D; + return BigDecimal.valueOf((double) numerator / denominator) + .setScale(4, RoundingMode.HALF_UP).doubleValue(); + } + + private long number(Map row, String key) { + Object value = row.get(key); + return value instanceof Number number ? number.longValue() : 0L; + } + + private BigDecimal decimal(Object value) { + if (value instanceof BigDecimal number) return number; + if (value instanceof Number number) return BigDecimal.valueOf(number.doubleValue()); + try { + return new BigDecimal(String.valueOf(value)); + } catch (Exception ignored) { + return BigDecimal.ZERO; + } + } + + private String text(Map row, String key) { + Object value = row.get(key); + return value == null ? "" : clean(String.valueOf(value)); + } + + private String value(Map row, String key) { + Object value = row.get(key); + return value == null ? "" : String.valueOf(value); + } + + private String clean(String value) { + return value == null ? "" : value.trim(); + } + + private record Recipient( + String role, + String name, + String wecomUserId, + String primaryOrganization, + String manageScope + ) {} + + private record RecipientDirectory(Map users, List managers) { + List managersFor(String campusName) { + String campus = campusName == null ? "" : campusName.trim().toLowerCase(Locale.ROOT); + if (campus.isEmpty()) return List.of(); + return managers.stream().filter(manager -> + manager.primaryOrganization().toLowerCase(Locale.ROOT).contains(campus) + || manager.manageScope().toLowerCase(Locale.ROOT).contains(campus)).toList(); + } + } + + private record LowHoursResult(long totalCount, BigDecimal totalRemainingHours, List> alerts) {} + + private record ScheduleResult( + long scheduledStudentCount, + long scheduleCount, + long evaluatedCount, + long attendanceCount, + long absenceCount, + long frequentAbsenceCount, + List> frequentAbsenceAlerts + ) {} + + private static final class StudentSchedule { + private final String studentId; + private final String studentName; + private final String campusId; + private final String campusName; + private String coachId = ""; + private String coachName = ""; + private String advisorId = ""; + private String advisorName = ""; + private long scheduleCount; + private long absenceCount; + + private StudentSchedule(String studentId, String studentName, String campusId, String campusName) { + this.studentId = studentId; + this.studentName = studentName; + this.campusId = campusId; + this.campusName = campusName; + } + + private void addCoach(String id, String name) { + if (coachId.isEmpty() && !id.isEmpty()) coachId = id; + if (coachName.isEmpty() && !name.isEmpty()) coachName = name; + } + + private void addAdvisor(String id, String name) { + if (advisorId.isEmpty() && !id.isEmpty()) advisorId = id; + if (advisorName.isEmpty() && !name.isEmpty()) advisorName = name; + } + + private Map toAlert() { + Map row = new LinkedHashMap<>(); + row.put("studentId", studentId); + row.put("studentName", studentName); + row.put("campusId", campusId); + row.put("campusName", campusName); + row.put("scheduleCount", scheduleCount); + row.put("absenceCount", absenceCount); + row.put("coachId", coachId); + row.put("coachName", coachName); + row.put("advisorId", advisorId); + row.put("advisorName", advisorName); + row.put("warningType", "FREQUENT_ABSENCE"); + return row; + } + } +} diff --git a/src/test/java/com/company/agentfoundation/semantic/SemanticRecognitionServiceTest.java b/src/test/java/com/company/agentfoundation/semantic/SemanticRecognitionServiceTest.java index 8eaf28c..55c3887 100644 --- a/src/test/java/com/company/agentfoundation/semantic/SemanticRecognitionServiceTest.java +++ b/src/test/java/com/company/agentfoundation/semantic/SemanticRecognitionServiceTest.java @@ -42,4 +42,13 @@ class SemanticRecognitionServiceTest { assertEquals("WECOM_CUSTOMER_OPERATIONS_DAILY_REPORT", recognition.intent()); assertEquals("KEYWORD_FALLBACK", recognition.source()); } + + @Test + void recognizesStudentTeachingAttendanceReportWithKeywordFallback() { + SemanticRecognition recognition = service.recognize( + "查看今日学员教学出勤日报", LocalDate.of(2026, 7, 21)); + + assertEquals("STUDENT_TEACHING_ATTENDANCE_DAILY_REPORT", recognition.intent()); + assertEquals("KEYWORD_FALLBACK", recognition.source()); + } }