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