学员教学出勤日报

This commit is contained in:
tanglong 2026-07-21 16:53:09 +08:00
parent f684b4db18
commit dafa464240
6 changed files with 623 additions and 0 deletions

View File

@ -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("运营"))) {

View File

@ -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**交付概览** 学员量:<font color=\"info\">")
.append(metrics.path("studentCount").asLong()).append("</font>")
.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(" | 出勤率:<font color=\"info\">")
.append(percent(metrics.path("attendanceRate").asDouble())).append("</font>")
.append("\n\n**课时预警** 低于10课时:<font color=\"warning\">")
.append(lowHours.path("studentCount").asInt()).append("人</font>")
.append(" | 剩余课时合计:").append(number(lowHours.path("remainingHoursTotal").asDouble()))
.append("\n\n**高频缺勤** 排课≥3次且缺勤≥2次:<font color=\"warning\">")
.append(absence.path("studentCount").asInt()).append("人</font>")
.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<font color=\"warning\">部分预警未匹配企业微信 UserID,请补全人员映射。</font>");
}
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);
}
}

View File

@ -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<String, Object> dailyReport(@RequestBody StudentTeachingAttendanceReportRequest request) {
return reportService.generate(request);
}
}

View File

@ -0,0 +1,9 @@
package com.company.agentfoundation.teaching.report;
import java.time.LocalDate;
public record StudentTeachingAttendanceReportRequest(
LocalDate startDate,
LocalDate endDate,
String wecomUserId
) {}

View File

@ -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<String, Object> 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<Map<String, Object>> lowHoursAlerts = lowHours.alerts();
List<Map<String, Object>> absenceAlerts = schedule.frequentAbsenceAlerts();
RecipientDirectory recipientDirectory = loadRecipientDirectory();
enrichRecipients(lowHoursAlerts, recipientDirectory);
enrichRecipients(absenceAlerts, recipientDirectory);
Set<String> pushUserIds = new LinkedHashSet<>();
lowHoursAlerts.forEach(row -> collectPushUserIds(row, pushUserIds));
absenceAlerts.forEach(row -> collectPushUserIds(row, pushUserIds));
Map<String, Object> 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<Map<String, Object>> alerts = jdbc.query(lowHoursSql(), params, (rs, rowNum) -> {
Map<String, Object> 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<String, StudentSchedule> 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<Map<String, Object>> 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<Map<String, Object>> alerts, RecipientDirectory directory) {
for (Map<String, Object> alert : alerts) {
List<Map<String, Object>> 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<String, Recipient> 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<Recipient> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> row, Set<String> 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<String, Object> 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<String, Object> 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<String, Recipient> users, List<Recipient> managers) {
List<Recipient> 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<Map<String, Object>> alerts) {}
private record ScheduleResult(
long scheduledStudentCount,
long scheduleCount,
long evaluatedCount,
long attendanceCount,
long absenceCount,
long frequentAbsenceCount,
List<Map<String, Object>> 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<String, Object> toAlert() {
Map<String, Object> 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;
}
}
}

View File

@ -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());
}
}