feat(aihr): scope supervisor practice data

This commit is contained in:
2026-07-09 11:37:33 +08:00
parent 53698e7832
commit a83fb53ecd
11 changed files with 406 additions and 31 deletions
@@ -54,18 +54,18 @@ public class AihrMobileController {
@GetMapping("/practice/reviews")
public R<List<RecordResponse>> practiceReviews() {
return R.ok(mobileSeedService.practiceReviews());
return R.ok(mobileSeedService.practiceReviews(currentAppUsername()));
}
@GetMapping("/practice/reviews/{id}")
public R<Map<String, Object>> practiceReview(@PathVariable Long id) {
ReviewDetailResponse detail = mobileSeedService.practiceReview(id);
ReviewDetailResponse detail = mobileSeedService.practiceReview(id, currentAppUsername());
return detail == null ? R.fail("复盘记录不存在") : R.ok(reviewDetailPayload(detail));
}
@PostMapping("/practice/reviews/{id}/reviewed")
public R<Void> markReviewed(@PathVariable Long id, @RequestBody(required = false) ReviewAdviceRequest request) {
return mobileSeedService.markReviewed(id, request) ? R.ok() : R.fail("复盘记录不存在");
return mobileSeedService.markReviewed(id, request, currentAppUsername()) ? R.ok() : R.fail("复盘记录不存在");
}
@GetMapping("/profile")
@@ -80,17 +80,17 @@ public class AihrMobileController {
@GetMapping("/practice/alerts")
public R<List<PracticeAlertResponse>> practiceAlerts() {
return R.ok(mobileSeedService.practiceAlerts());
return R.ok(mobileSeedService.practiceAlerts(currentAppUsername()));
}
@GetMapping("/practice/assignments")
public R<List<PracticeAssignmentResponse>> practiceAssignments(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId)));
return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId), currentAppUsername()));
}
@PostMapping("/practice/assignments")
public R<PracticeAssignmentResponse> createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) {
return R.ok(mobileSeedService.createPracticeAssignment(request));
return R.ok(mobileSeedService.createPracticeAssignment(request, currentAppUsername()));
}
@PostMapping("/practice/assignments/{id}/answer")
@@ -53,10 +53,18 @@ public class AihrMobileSeedService {
return practiceSeedService.pendingReviewRecords(5);
}
public List<RecordResponse> practiceReviews(String supervisorExtPartyId) {
return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, 5);
}
public ReviewDetailResponse practiceReview(Long id) {
return practiceSeedService.reviewDetail(id);
}
public ReviewDetailResponse practiceReview(Long id, String supervisorExtPartyId) {
return practiceSeedService.reviewDetail(id, supervisorExtPartyId);
}
public boolean markReviewed(Long id) {
return markReviewed(id, null);
}
@@ -65,6 +73,10 @@ public class AihrMobileSeedService {
return practiceSeedService.markReviewed(id, request);
}
public boolean markReviewed(Long id, ReviewAdviceRequest request, String supervisorExtPartyId) {
return practiceSeedService.markReviewed(id, request, supervisorExtPartyId);
}
public CompetencyResponse profile(String extPartyId) {
return practiceSeedService.competency(extPartyId);
}
@@ -77,14 +89,26 @@ public class AihrMobileSeedService {
return practiceSeedService.alerts();
}
public List<PracticeAlertResponse> practiceAlerts(String supervisorExtPartyId) {
return practiceSeedService.alerts(supervisorExtPartyId);
}
public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId) {
return practiceSeedService.assignments(extPartyId, 10);
}
public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId, String supervisorExtPartyId) {
return practiceSeedService.assignments(extPartyId, supervisorExtPartyId, 10);
}
public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request) {
return practiceSeedService.createAssignment(request);
}
public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) {
return practiceSeedService.createAssignment(request, supervisorExtPartyId);
}
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) {
return answerDailyDrill(id, request, "");
}
@@ -488,6 +488,29 @@ public class AihrPracticeSeedService {
""", this::mapRecord, TENANT_ID, normalizeLimit(limit));
}
public List<RecordResponse> pendingReviewRecords(String supervisorExtPartyId, int limit) {
TeamScope scope = teamScope(supervisorExtPartyId);
if (!scope.scoped()) {
return pendingReviewRecords(limit);
}
if (scope.extPartyIds().isEmpty()) {
return List.of();
}
ensurePracticeTable();
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
args.addAll(scope.extPartyIds());
args.add(normalizeLimit(limit));
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'
AND ext_party_id IN (%s)
ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC
LIMIT ?
""".formatted(inClause(scope.extPartyIds().size())), this::mapRecord, args.toArray());
}
public String exportPilotCsv() {
ensurePracticeTable();
ensureAssignmentTable();
@@ -564,6 +587,11 @@ public class AihrPracticeSeedService {
return rows.isEmpty() ? null : rows.get(0);
}
public ReviewDetailResponse reviewDetail(Long id, String supervisorExtPartyId) {
ReviewDetailResponse detail = reviewDetail(id);
return detail == null || !inTeamScope(supervisorExtPartyId, reviewExtPartyId(id)) ? null : detail;
}
public boolean markReviewed(Long id) {
return markReviewed(id, null);
}
@@ -604,6 +632,13 @@ public class AihrPracticeSeedService {
return updated > 0;
}
public boolean markReviewed(Long id, ReviewAdviceRequest request, String supervisorExtPartyId) {
if (reviewDetail(id, supervisorExtPartyId) == null) {
return false;
}
return markReviewed(id, request);
}
public List<AnnotationResponse> saveAnnotations(String sessionId, AnnotationSaveRequest request) {
ensurePracticeTable();
if (isBlank(sessionId)) {
@@ -820,6 +855,27 @@ public class AihrPracticeSeedService {
return result;
}
public List<PracticeAlertResponse> alerts(String supervisorExtPartyId) {
TeamScope scope = teamScope(supervisorExtPartyId);
if (!scope.scoped()) {
return alerts();
}
ensurePracticeTable();
int pending = countPendingReview(scope);
int lowScore = countLowScore(scope);
List<PracticeAlertResponse> result = new ArrayList<>();
if (pending > 0) {
result.add(new PracticeAlertResponse("pending_review", "待复盘积压", pending + " 条对练等待主管复盘", "medium", ""));
}
if (lowScore > 0) {
result.add(new PracticeAlertResponse("low_score", "低分训练预警", lowScore + " 条训练低于 80 分,建议派发定向再练", "high", ""));
}
if (result.isEmpty()) {
result.add(new PracticeAlertResponse("stable", "暂无高风险预警", "团队训练记录稳定,可继续按今日安排推进", "low", ""));
}
return result;
}
public List<PracticeAssignmentResponse> assignments(String extPartyId, int limit) {
ensureAssignmentTable();
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
@@ -840,6 +896,39 @@ public class AihrPracticeSeedService {
""", this::mapAssignment, TENANT_ID, party, party, normalizeLimit(limit));
}
public List<PracticeAssignmentResponse> assignments(String extPartyId, String supervisorExtPartyId, int limit) {
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
if (!isBlank(party)) {
return inTeamScope(supervisorExtPartyId, party) ? assignments(party, limit) : List.of();
}
TeamScope scope = teamScope(supervisorExtPartyId);
if (!scope.scoped()) {
return assignments("", limit);
}
if (scope.extPartyIds().isEmpty()) {
return List.of();
}
ensureAssignmentTable();
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
args.addAll(scope.extPartyIds());
args.add(normalizeLimit(limit));
return jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, feedback, correct
FROM aihr_practice_assignment
WHERE tenant_id = ?
AND ext_party_id IN (%s)
AND (status = '待训练' OR (source = 'daily' AND DATE(create_time) = CURRENT_DATE()))
ORDER BY
CASE WHEN source = 'daily' THEN 1 ELSE 0 END,
CASE WHEN source = 'daily' THEN id ELSE NULL END ASC,
create_time DESC,
id DESC
LIMIT ?
""".formatted(inClause(scope.extPartyIds().size())), this::mapAssignment, args.toArray());
}
private void ensureDailyDrills(String extPartyId) {
if (isBlank(extPartyId) || countMobileRecords(extPartyId) >= 3) {
return;
@@ -904,6 +993,14 @@ public class AihrPracticeSeedService {
return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null) : rows.get(0);
}
public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) {
String extPartyId = request == null ? "" : firstNonBlank(request.extPartyId(), "");
if (!inTeamScope(supervisorExtPartyId, extPartyId)) {
throw new ServiceException("只能派发给当前项目范围内员工");
}
return createAssignment(request);
}
private void markAssignmentCompleted(Long requestAssignmentId, ActiveSession activeSession) {
Long assignmentId = requestAssignmentId == null && activeSession != null ? activeSession.assignmentId() : requestAssignmentId;
if (assignmentId == null || activeSession == null || isBlank(activeSession.extPartyId())) {
@@ -1208,6 +1305,88 @@ public class AihrPracticeSeedService {
""", TENANT_ID, extPartyId.trim());
}
private int countPendingReview(TeamScope scope) {
if (scope.extPartyIds().isEmpty()) {
return 0;
}
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
args.addAll(scope.extPartyIds());
return count("""
SELECT COUNT(*)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'
AND ext_party_id IN (%s)
""".formatted(inClause(scope.extPartyIds().size())), args.toArray());
}
private int countLowScore(TeamScope scope) {
if (scope.extPartyIds().isEmpty()) {
return 0;
}
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
args.addAll(scope.extPartyIds());
return count("""
SELECT COUNT(*)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND total_score < 80
AND ext_party_id IN (%s)
""".formatted(inClause(scope.extPartyIds().size())), args.toArray());
}
private boolean inTeamScope(String supervisorExtPartyId, String extPartyId) {
if (isBlank(extPartyId)) {
return false;
}
TeamScope scope = teamScope(supervisorExtPartyId);
return !scope.scoped() || scope.extPartyIds().contains(extPartyId.trim());
}
private TeamScope teamScope(String supervisorExtPartyId) {
String owner = isBlank(supervisorExtPartyId) ? "" : supervisorExtPartyId.trim();
if (owner.isEmpty() || !orgSnapshotTableExists()) {
return TeamScope.unscoped();
}
List<OrgScopeRow> owners = jdbcTemplate.query("""
SELECT project_code, position_level
FROM aihr_org_snapshot
WHERE tenant_id = ? AND ext_party_id = ? AND employment_status = 'active'
LIMIT 1
""", (rs, rowNum) -> new OrgScopeRow(rs.getString("project_code"), rs.getString("position_level")), TENANT_ID, owner);
if (owners.isEmpty()) {
return TeamScope.unscoped();
}
OrgScopeRow row = owners.get(0);
if (!canSeeProject(row.positionLevel())) {
return TeamScope.scoped(List.of(owner));
}
List<String> ids = jdbcTemplate.query("""
SELECT ext_party_id
FROM aihr_org_snapshot
WHERE tenant_id = ? AND project_code = ? AND employment_status = 'active'
ORDER BY position_level, person_name, ext_party_id
""", (rs, rowNum) -> rs.getString("ext_party_id"), TENANT_ID, row.projectCode());
return TeamScope.scoped(ids);
}
private boolean orgSnapshotTableExists() {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aihr_org_snapshot'
""", Integer.class);
return count != null && count > 0;
}
private static boolean canSeeProject(String positionLevel) {
return "主管".equals(positionLevel) || "项目经理".equals(positionLevel);
}
private static String inClause(int size) {
return String.join(",", java.util.Collections.nCopies(size, "?"));
}
private int count(String sql, Object... args) {
Integer value = jdbcTemplate.queryForObject(sql, Integer.class, args);
return value == null ? 0 : value;
@@ -2567,6 +2746,19 @@ public class AihrPracticeSeedService {
private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
}
private record OrgScopeRow(String projectCode, String positionLevel) {
}
private record TeamScope(boolean scoped, List<String> extPartyIds) {
static TeamScope unscoped() {
return new TeamScope(false, List.of());
}
static TeamScope scoped(List<String> extPartyIds) {
return new TeamScope(true, extPartyIds);
}
}
private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt,
List<String> traineeReplies, List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
List<String> customerLines, List<TurnEvidence> turnEvidence) {
@@ -4,9 +4,23 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.invocation.InvocationOnMock;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class AihrPracticeSeedServiceTest {
@@ -24,4 +38,39 @@ public class AihrPracticeSeedServiceTest {
)));
assertEquals("缺少员工ID,无法派发专项训练", error.getMessage());
}
@Test
public void createAssignmentRejectsOutOfProjectAssignee() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.queryForObject(ArgumentMatchers.anyString(), eq(Integer.class))).thenReturn(1);
when(jdbcTemplate.query(contains("SELECT project_code"), any(RowMapper.class), eq("000000"), eq("SUP-1")))
.thenAnswer(invocation -> mapOne(invocation, Map.of(
"project_code", "P1",
"position_level", "主管"
)));
when(jdbcTemplate.query(contains("SELECT ext_party_id"), any(RowMapper.class), eq("000000"), eq("P1")))
.thenAnswer(invocation -> mapOne(invocation, Map.of("ext_party_id", "EMP-1")));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
ServiceException error = assertThrows(ServiceException.class, () -> service.createAssignment(new PracticeAssignmentRequest(
"EMP-2",
"越权员工",
"fee-parking",
null,
"manual",
"主管派发专项训练"
), "SUP-1"));
assertEquals("只能派发给当前项目范围内员工", error.getMessage());
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static <T> List<T> mapOne(InvocationOnMock invocation, Map<String, String> values) throws SQLException {
RowMapper mapper = invocation.getArgument(1);
ResultSet resultSet = mock(ResultSet.class);
for (Map.Entry<String, String> entry : values.entrySet()) {
when(resultSet.getString(entry.getKey())).thenReturn(entry.getValue());
}
return List.of((T) mapper.mapRow(resultSet, 0));
}
}