学员教学出勤日报
This commit is contained in:
parent
f684b4db18
commit
dafa464240
@ -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("运营"))) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.company.agentfoundation.teaching.report;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record StudentTeachingAttendanceReportRequest(
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String wecomUserId
|
||||
) {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user