fix(aihr): harden broadcast and mobile assistant flows
This commit is contained in:
+2
-2
@@ -187,8 +187,8 @@ public class AihrMobileController {
|
||||
}
|
||||
|
||||
@GetMapping("/practice/team")
|
||||
public R<PracticeTeamResponse> practiceTeam() {
|
||||
return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId()));
|
||||
public R<PracticeTeamResponse> practiceTeam(@RequestParam(defaultValue = "false") boolean includeProfiles) {
|
||||
return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId(), includeProfiles));
|
||||
}
|
||||
|
||||
@GetMapping("/practice/reviews/{id}")
|
||||
|
||||
+18
-1
@@ -424,11 +424,28 @@ public final class AihrPracticeDto {
|
||||
public record TeamMemberResponse(String extPartyId, String name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, full-team activity aggregate. Unlike the recent-detail lists,
|
||||
* this is not capped globally so risk labels remain correct for every member.
|
||||
*/
|
||||
public record PracticeTeamProfileResponse(
|
||||
String extPartyId,
|
||||
Integer latestScore,
|
||||
String latestScene,
|
||||
String lastPracticeAt,
|
||||
Boolean completedToday,
|
||||
Integer pendingReview,
|
||||
Integer pendingLowScore,
|
||||
Integer pendingAssignment
|
||||
) {
|
||||
}
|
||||
|
||||
public record PracticeTeamResponse(
|
||||
List<TeamMemberResponse> members,
|
||||
List<RecordResponse> records,
|
||||
List<PracticeAssignmentResponse> assignments,
|
||||
List<MistakeAggregateResponse> mistakes
|
||||
List<MistakeAggregateResponse> mistakes,
|
||||
List<PracticeTeamProfileResponse> profiles
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -103,7 +103,11 @@ public class AihrMobileSeedService {
|
||||
}
|
||||
|
||||
public PracticeTeamResponse practiceTeam(String supervisorExtPartyId) {
|
||||
return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200);
|
||||
return practiceTeam(supervisorExtPartyId, false);
|
||||
}
|
||||
|
||||
public PracticeTeamResponse practiceTeam(String supervisorExtPartyId, boolean includeProfiles) {
|
||||
return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200, includeProfiles);
|
||||
}
|
||||
|
||||
public String requireSupervisorIdentity(String authenticatedIdentity) {
|
||||
|
||||
+192
-5
@@ -26,6 +26,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamProfileResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
|
||||
@@ -988,19 +989,25 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
|
||||
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) {
|
||||
return practiceTeamSnapshot(supervisorExtPartyId, limit, false);
|
||||
}
|
||||
|
||||
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit, boolean includeProfiles) {
|
||||
if (!orgSnapshotTableExists() && !isDemoProfile()) {
|
||||
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of());
|
||||
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
TeamScope scope = teamScope(supervisorExtPartyId);
|
||||
int safeLimit = normalizeTeamLimit(limit);
|
||||
if (scope.scoped() && scope.extPartyIds().isEmpty()) {
|
||||
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of());
|
||||
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
List<TeamMemberResponse> members = practiceTeamMembers(scope);
|
||||
return new PracticeTeamResponse(
|
||||
practiceTeamMembers(scope),
|
||||
members,
|
||||
practiceTeamRecords(scope, safeLimit),
|
||||
practiceTeamAssignments(scope, safeLimit),
|
||||
practiceTeamMistakes(scope, safeLimit)
|
||||
practiceTeamMistakes(scope, safeLimit),
|
||||
includeProfiles ? practiceTeamProfileResponses(scope, members) : List.of()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1146,6 +1153,162 @@ public class AihrPracticeSeedService {
|
||||
return jdbcTemplate.query(sql, this::mapAssignment, args.toArray());
|
||||
}
|
||||
|
||||
private List<PracticeTeamProfileResponse> practiceTeamProfileResponses(TeamScope scope,
|
||||
List<TeamMemberResponse> members) {
|
||||
return buildTeamProfileResponses(
|
||||
members,
|
||||
practiceTeamProfileRecordSummaries(scope),
|
||||
practiceTeamProfileAssignmentSummaries(scope)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The team list cannot derive risk from the recent-detail payload because that
|
||||
* payload is intentionally capped. Aggregate all activity server-side, then
|
||||
* return only one compact fact row for each active member.
|
||||
*/
|
||||
static List<PracticeTeamProfileResponse> buildTeamProfileResponses(
|
||||
List<TeamMemberResponse> members,
|
||||
List<TeamProfileRecordSummary> records,
|
||||
List<TeamProfileAssignmentSummary> assignments
|
||||
) {
|
||||
Map<String, TeamProfileAggregate> aggregates = new LinkedHashMap<>();
|
||||
for (TeamMemberResponse member : members) {
|
||||
String extPartyId = firstNonBlankStatic(member == null ? null : member.extPartyId(), "");
|
||||
if (!extPartyId.isEmpty()) {
|
||||
aggregates.putIfAbsent(extPartyId, new TeamProfileAggregate(extPartyId));
|
||||
}
|
||||
}
|
||||
|
||||
for (TeamProfileRecordSummary record : records) {
|
||||
TeamProfileAggregate aggregate = aggregates.get(firstNonBlankStatic(record == null ? null : record.extPartyId(), ""));
|
||||
if (aggregate == null || record == null) {
|
||||
continue;
|
||||
}
|
||||
aggregate.latestScore = record.latestScore();
|
||||
aggregate.latestScene = firstNonBlankStatic(record.latestScene(), "");
|
||||
aggregate.latestPracticeAt = record.latestPracticeAt();
|
||||
aggregate.completedToday = record.completedToday();
|
||||
aggregate.pendingReview = record.pendingReview();
|
||||
aggregate.pendingLowScore = record.pendingLowScore();
|
||||
}
|
||||
|
||||
for (TeamProfileAssignmentSummary assignment : assignments) {
|
||||
TeamProfileAggregate aggregate = aggregates.get(firstNonBlankStatic(assignment == null ? null : assignment.extPartyId(), ""));
|
||||
if (aggregate == null || assignment == null) {
|
||||
continue;
|
||||
}
|
||||
aggregate.pendingAssignment = assignment.pendingAssignment();
|
||||
aggregate.latestAssignmentScene = firstNonBlankStatic(assignment.latestScene(), "");
|
||||
}
|
||||
|
||||
List<PracticeTeamProfileResponse> result = new ArrayList<>();
|
||||
for (TeamProfileAggregate aggregate : aggregates.values()) {
|
||||
result.add(new PracticeTeamProfileResponse(
|
||||
aggregate.extPartyId,
|
||||
aggregate.latestScore,
|
||||
firstNonBlankStatic(aggregate.latestScene, firstNonBlankStatic(aggregate.latestAssignmentScene, "")),
|
||||
formatTimestamp(aggregate.latestPracticeAt),
|
||||
aggregate.completedToday,
|
||||
aggregate.pendingReview,
|
||||
aggregate.pendingLowScore,
|
||||
aggregate.pendingAssignment
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<TeamProfileRecordSummary> practiceTeamProfileRecordSummaries(TeamScope scope) {
|
||||
ensurePracticeTable();
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(tenantId());
|
||||
StringBuilder where = new StringBuilder("WHERE s.tenant_id = ? AND s.mode = 'mobile'");
|
||||
if (scope.scoped()) {
|
||||
where.append(" AND s.ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
|
||||
args.addAll(scope.extPartyIds());
|
||||
}
|
||||
boolean hasOrgSnapshot = orgSnapshotTableExists();
|
||||
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
|
||||
String identityExpression = hasOrgSnapshot
|
||||
? teamCanonicalIdentityExpression("s", hasPhone)
|
||||
: "s.ext_party_id";
|
||||
LocalDate today = LocalDate.now(BUSINESS_ZONE);
|
||||
args.add(Timestamp.valueOf(today.atStartOfDay()));
|
||||
args.add(Timestamp.valueOf(today.plusDays(1).atStartOfDay()));
|
||||
String sql = """
|
||||
WITH scoped_records AS (
|
||||
SELECT %s AS ext_party_id, s.scenario_name, s.total_score, s.status, s.finished_time, s.id
|
||||
FROM aihr_practice_session s
|
||||
%s
|
||||
), ranked_records AS (
|
||||
SELECT scoped_records.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY ext_party_id
|
||||
ORDER BY finished_time DESC, id DESC
|
||||
) AS latest_rank
|
||||
FROM scoped_records
|
||||
)
|
||||
SELECT ext_party_id,
|
||||
MAX(CASE WHEN latest_rank = 1 THEN total_score END) AS latest_score,
|
||||
MAX(CASE WHEN latest_rank = 1 THEN scenario_name END) AS latest_scene,
|
||||
MAX(CASE WHEN latest_rank = 1 THEN finished_time END) AS latest_practice_at,
|
||||
MAX(CASE WHEN finished_time >= ? AND finished_time < ? THEN 1 ELSE 0 END) AS completed_today,
|
||||
SUM(CASE WHEN status = '待复盘' THEN 1 ELSE 0 END) AS pending_review,
|
||||
MIN(CASE WHEN status = '待复盘' AND total_score < 80 THEN total_score END) AS pending_low_score
|
||||
FROM ranked_records
|
||||
GROUP BY ext_party_id
|
||||
""".formatted(identityExpression, where);
|
||||
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamProfileRecordSummary(
|
||||
rs.getString("ext_party_id"),
|
||||
rs.getObject("latest_score") == null ? null : rs.getInt("latest_score"),
|
||||
rs.getString("latest_scene"),
|
||||
rs.getTimestamp("latest_practice_at"),
|
||||
rs.getInt("completed_today") > 0,
|
||||
rs.getInt("pending_review"),
|
||||
rs.getObject("pending_low_score") == null ? null : rs.getInt("pending_low_score")
|
||||
), args.toArray());
|
||||
}
|
||||
|
||||
private List<TeamProfileAssignmentSummary> practiceTeamProfileAssignmentSummaries(TeamScope scope) {
|
||||
ensureAssignmentTable();
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(tenantId());
|
||||
StringBuilder where = new StringBuilder("WHERE a.tenant_id = ? AND a.source <> 'daily'");
|
||||
if (scope.scoped()) {
|
||||
where.append(" AND a.ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
|
||||
args.addAll(scope.extPartyIds());
|
||||
}
|
||||
boolean hasOrgSnapshot = orgSnapshotTableExists();
|
||||
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
|
||||
String identityExpression = hasOrgSnapshot
|
||||
? teamCanonicalIdentityExpression("a", hasPhone)
|
||||
: "a.ext_party_id";
|
||||
String sql = """
|
||||
WITH scoped_assignments AS (
|
||||
SELECT %s AS ext_party_id, a.scenario_name, a.status, a.create_time, a.id
|
||||
FROM aihr_practice_assignment a
|
||||
%s
|
||||
), ranked_assignments AS (
|
||||
SELECT scoped_assignments.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY ext_party_id
|
||||
ORDER BY create_time DESC, id DESC
|
||||
) AS latest_rank
|
||||
FROM scoped_assignments
|
||||
)
|
||||
SELECT ext_party_id,
|
||||
MAX(CASE WHEN latest_rank = 1 THEN scenario_name END) AS latest_scene,
|
||||
SUM(CASE WHEN status IS NULL OR status <> '已完成' THEN 1 ELSE 0 END) AS pending_assignment
|
||||
FROM ranked_assignments
|
||||
GROUP BY ext_party_id
|
||||
""".formatted(identityExpression, where);
|
||||
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamProfileAssignmentSummary(
|
||||
rs.getString("ext_party_id"),
|
||||
rs.getString("latest_scene"),
|
||||
rs.getInt("pending_assignment")
|
||||
), args.toArray());
|
||||
}
|
||||
|
||||
private List<MistakeAggregateResponse> practiceTeamMistakes(TeamScope scope, int limit) {
|
||||
ensurePracticeTable();
|
||||
List<Object> args = new ArrayList<>();
|
||||
@@ -4597,7 +4760,7 @@ public class AihrPracticeSeedService {
|
||||
return Math.min(limit, 200);
|
||||
}
|
||||
|
||||
private String formatTimestamp(Timestamp value) {
|
||||
private static String formatTimestamp(Timestamp value) {
|
||||
return value == null ? "" : value.toLocalDateTime().format(TIME_FORMATTER);
|
||||
}
|
||||
|
||||
@@ -5341,6 +5504,30 @@ public class AihrPracticeSeedService {
|
||||
Set<String> ambiguousExtPartyIds) {
|
||||
}
|
||||
|
||||
static record TeamProfileRecordSummary(String extPartyId, Integer latestScore, String latestScene,
|
||||
Timestamp latestPracticeAt, boolean completedToday, int pendingReview,
|
||||
Integer pendingLowScore) {
|
||||
}
|
||||
|
||||
static record TeamProfileAssignmentSummary(String extPartyId, String latestScene, int pendingAssignment) {
|
||||
}
|
||||
|
||||
private static final class TeamProfileAggregate {
|
||||
private final String extPartyId;
|
||||
private Integer latestScore;
|
||||
private String latestScene = "";
|
||||
private Timestamp latestPracticeAt;
|
||||
private boolean completedToday;
|
||||
private Integer pendingLowScore;
|
||||
private int pendingReview;
|
||||
private int pendingAssignment;
|
||||
private String latestAssignmentScene = "";
|
||||
|
||||
private TeamProfileAggregate(String extPartyId) {
|
||||
this.extPartyId = extPartyId;
|
||||
}
|
||||
}
|
||||
|
||||
private record TeamScope(boolean scoped, List<String> extPartyIds) {
|
||||
static TeamScope unscoped() {
|
||||
return new TeamScope(false, List.of());
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ class AihrKnowledgeDataToolServiceTest {
|
||||
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
|
||||
when(mobile.requireSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor");
|
||||
when(mobile.practiceTeam("canonical-supervisor"))
|
||||
.thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of()));
|
||||
.thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of()));
|
||||
var service = new AihrKnowledgeDataToolService(mobile);
|
||||
var principal = new AihrKnowledgePrincipal("000000", 2L, "app_user", "supervisor-1",
|
||||
Set.of("employee", "supervisor"), Set.of("P1"), "app");
|
||||
|
||||
+49
-3
@@ -8,9 +8,11 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamProfileResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
||||
import org.dromara.common.core.constant.HttpStatus;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
@@ -235,6 +237,50 @@ public class AihrPracticeSeedServiceTest {
|
||||
assertTrue(code.contains("if (!orgSnapshotTableExists() && !isDemoProfile())"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void teamProfilesAggregateEveryMemberBeforeThePageWindow() throws Exception {
|
||||
List<TeamMemberResponse> members = new ArrayList<>();
|
||||
for (int index = 0; index < 201; index++) {
|
||||
members.add(new TeamMemberResponse("EMP-" + index, "员工" + index));
|
||||
}
|
||||
|
||||
List<PracticeTeamProfileResponse> profiles = AihrPracticeSeedService.buildTeamProfileResponses(
|
||||
members,
|
||||
List.of(new AihrPracticeSeedService.TeamProfileRecordSummary(
|
||||
"EMP-200", 62, "投诉接待", Timestamp.valueOf("2026-07-23 10:00:00"), true, 1, 62
|
||||
)),
|
||||
List.of(new AihrPracticeSeedService.TeamProfileAssignmentSummary(
|
||||
"EMP-200", "投诉接待", 1
|
||||
))
|
||||
);
|
||||
|
||||
PracticeTeamProfileResponse lastMember = profiles.stream()
|
||||
.filter(item -> "EMP-200".equals(item.extPartyId()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals(62, lastMember.pendingLowScore());
|
||||
assertEquals(1, lastMember.pendingReview());
|
||||
assertEquals(1, lastMember.pendingAssignment());
|
||||
assertTrue(lastMember.completedToday());
|
||||
|
||||
String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
|
||||
int methodStart = source.indexOf("private List<TeamProfileRecordSummary> practiceTeamProfileRecordSummaries");
|
||||
int methodEnd = source.indexOf("private List<TeamProfileAssignmentSummary> practiceTeamProfileAssignmentSummaries", methodStart);
|
||||
assertTrue(methodStart >= 0 && methodEnd > methodStart);
|
||||
String recordSummarySource = source.substring(methodStart, methodEnd);
|
||||
assertFalse(recordSummarySource.contains("LIMIT ?"));
|
||||
assertTrue(recordSummarySource.contains("ROW_NUMBER() OVER"));
|
||||
assertTrue(recordSummarySource.contains("GROUP BY ext_party_id"));
|
||||
assertTrue(recordSummarySource.contains("AS completed_today"));
|
||||
int assignmentMethodEnd = source.indexOf("private List<MistakeAggregateResponse> practiceTeamMistakes", methodEnd);
|
||||
assertTrue(assignmentMethodEnd > methodEnd);
|
||||
String assignmentSummarySource = source.substring(methodEnd, assignmentMethodEnd);
|
||||
assertFalse(assignmentSummarySource.contains("LIMIT ?"));
|
||||
assertTrue(assignmentSummarySource.contains("ROW_NUMBER() OVER"));
|
||||
assertTrue(assignmentSummarySource.contains("GROUP BY ext_party_id"));
|
||||
assertTrue(source.contains("includeProfiles ? practiceTeamProfileResponses(scope, members) : List.of()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void calibrationRequiresExplicitCorrectedScore() {
|
||||
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
|
||||
@@ -832,7 +878,8 @@ public class AihrPracticeSeedServiceTest {
|
||||
String controllerSource = Files.readString(source);
|
||||
|
||||
assertTrue(controllerSource.contains("mobileSeedService.practiceReviews(supervisorScopeExtPartyId(),"));
|
||||
assertTrue(controllerSource.contains("mobileSeedService.practiceTeam(supervisorScopeExtPartyId())"));
|
||||
assertTrue(controllerSource.contains("@RequestParam(defaultValue = \"false\") boolean includeProfiles"));
|
||||
assertTrue(controllerSource.contains("mobileSeedService.practiceTeam(supervisorScopeExtPartyId(), includeProfiles)"));
|
||||
assertTrue(controllerSource.contains("mobileSeedService.practiceReview(id, supervisorScopeExtPartyId())"));
|
||||
assertTrue(controllerSource.contains("mobileSeedService.markReviewed(id, bindReviewOperator(request), supervisorScopeExtPartyId())"));
|
||||
assertTrue(controllerSource.contains("mobileSeedService.practiceAlerts(supervisorScopeExtPartyId())"));
|
||||
@@ -1691,8 +1738,7 @@ public class AihrPracticeSeedServiceTest {
|
||||
"ext_party_id", extPartyId
|
||||
)));
|
||||
}
|
||||
if (sql.contains("FROM aihr_practice_assignment")
|
||||
&& (sql.contains("SELECT id, ext_party_id") || sql.contains("AS ext_party_id"))) {
|
||||
if (sql.contains("FROM aihr_practice_assignment") && sql.contains("SELECT id,")) {
|
||||
assignmentsSql = sql;
|
||||
assignmentsArgs = List.of(args);
|
||||
String extPartyId = sql.contains("ext_party_id IN")
|
||||
|
||||
Reference in New Issue
Block a user