feat(aihr): complete phase two pilot readiness

This commit is contained in:
2026-07-10 19:13:31 +08:00
parent 0219de6fa8
commit 13230e6b9a
48 changed files with 7040 additions and 1647 deletions
@@ -10,6 +10,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
@@ -63,6 +64,11 @@ public class AihrMobileController {
return R.ok(mobileSeedService.practiceReviews(supervisorScopeExtPartyId(), projectCode, extPartyId, limit));
}
@GetMapping("/practice/team")
public R<PracticeTeamResponse> practiceTeam() {
return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId()));
}
@GetMapping("/practice/reviews/{id}")
public R<Map<String, Object>> practiceReview(@PathVariable Long id) {
ReviewDetailResponse detail = mobileSeedService.practiceReview(id, supervisorScopeExtPartyId());
@@ -101,7 +107,7 @@ public class AihrMobileController {
@PostMapping("/practice/assignments")
public R<PracticeAssignmentResponse> createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) {
return R.ok(mobileSeedService.createPracticeAssignment(request, currentAppUsername()));
return R.ok(mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId()));
}
@PostMapping("/practice/assignments/{id}/answer")
@@ -117,15 +123,13 @@ public class AihrMobileController {
private static String scopedAssignmentExtPartyId(String requested) {
if (requested == null || requested.isBlank()) {
// ponytail: supervisor H5 keeps team-scope listing until mobile roles exist.
return requested;
}
return ownMobileExtPartyId(requested);
}
private static String supervisorScopeExtPartyId() {
// ponytail: supervisor H5 keeps team-scope reviews/alerts until mobile roles exist.
return "";
private String supervisorScopeExtPartyId() {
return mobileSeedService.requireSupervisorIdentity(currentAppUsername());
}
private static String currentAppUsername() {
@@ -26,6 +26,7 @@ import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.http.HttpHeaders;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -37,6 +38,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.List;
/**
@@ -86,12 +88,15 @@ public class AihrPracticeController {
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/export")
public void export(HttpServletResponse response) throws IOException {
byte[] body = ("\uFEFF" + practiceSeedService.exportPilotCsv()).getBytes(StandardCharsets.UTF_8);
public void export(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
HttpServletResponse response) throws IOException {
byte[] body = ("\uFEFF" + practiceSeedService.exportPilotCsv(startDate, endDate)).getBytes(StandardCharsets.UTF_8);
response.reset();
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("text/csv;charset=UTF-8");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=aihr-practice-pilot.csv");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=aihr-practice-pilot-" + startDate + "-" + endDate + ".csv");
response.setContentLength(body.length);
response.getOutputStream().write(body);
}
@@ -208,6 +208,16 @@ public final class AihrPracticeDto {
) {
}
public record TeamMemberResponse(String extPartyId, String name) {
}
public record PracticeTeamResponse(
List<TeamMemberResponse> members,
List<RecordResponse> records,
List<PracticeAssignmentResponse> assignments
) {
}
public record DailyDrillAnswerRequest(String answer) {
}
@@ -6,6 +6,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
@@ -60,6 +61,14 @@ public class AihrMobileSeedService {
return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, projectCode, extPartyId, limit == null ? 20 : limit);
}
public PracticeTeamResponse practiceTeam(String supervisorExtPartyId) {
return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200);
}
public String requireSupervisorIdentity(String authenticatedIdentity) {
return practiceSeedService.requireSupervisorIdentity(authenticatedIdentity);
}
public ReviewDetailResponse practiceReview(Long id) {
return practiceSeedService.reviewDetail(id);
}
@@ -71,11 +71,13 @@ public class AihrOrgSyncService {
if (baseUrl.isBlank()) {
throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀");
}
requireSnapshotTable();
int pageSize = clamp(req.pageSize(), DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE);
int maxPages = clamp(req.maxPages(), DEFAULT_MAX_PAGES, 1, DEFAULT_MAX_PAGES);
boolean dryRun = Boolean.TRUE.equals(req.dryRun());
if (!dryRun) {
requireSnapshotTable();
}
boolean replaceExisting = req.replaceExisting() == null || Boolean.TRUE.equals(req.replaceExisting());
String token = accessToken(baseUrl);
List<String> warnings = new ArrayList<>();
@@ -20,6 +20,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
@@ -35,15 +36,22 @@ import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -70,6 +78,115 @@ public class AihrPracticeSeedService {
"professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。"
);
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final String PILOT_SCOPE_CTE = """
WITH pilot_params AS (
SELECT ? AS tenant_id, ? AS start_time, ? AS end_time
), active_org AS (
SELECT DISTINCT o.ext_party_id, o.project_code, o.person_phone
FROM aihr_org_snapshot o
JOIN pilot_params p ON p.tenant_id = o.tenant_id
WHERE o.employment_status = 'active'
AND o.person_phone IS NOT NULL AND o.person_phone <> ''
), all_org_keys AS (
SELECT ext_party_id AS identity_key, ext_party_id
FROM active_org
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
UNION ALL
SELECT person_phone AS identity_key, ext_party_id
FROM active_org
), conflict_keys AS (
SELECT identity_key
FROM all_org_keys
GROUP BY identity_key
HAVING COUNT(DISTINCT ext_party_id) > 1
), unsafe_org AS (
SELECT DISTINCT k.ext_party_id
FROM all_org_keys k
JOIN conflict_keys c ON c.identity_key = k.identity_key
), safe_org AS (
SELECT o.ext_party_id, o.project_code, o.person_phone
FROM active_org o
LEFT JOIN unsafe_org u ON u.ext_party_id = o.ext_party_id
WHERE u.ext_party_id IS NULL
), org_key_candidates AS (
SELECT ext_party_id AS identity_key, ext_party_id
FROM safe_org
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
UNION ALL
SELECT person_phone AS identity_key, ext_party_id
FROM safe_org
), org_identity AS (
SELECT identity_key, MIN(ext_party_id) AS ext_party_id
FROM org_key_candidates
GROUP BY identity_key
HAVING COUNT(DISTINCT ext_party_id) = 1
), pilot_keys AS (
SELECT s.ext_party_id AS identity_key
FROM aihr_practice_session s
JOIN pilot_params p ON p.tenant_id = s.tenant_id
WHERE s.mode = 'mobile' AND s.finished_time >= p.start_time AND s.finished_time < p.end_time
UNION
SELECT a.ext_party_id AS identity_key
FROM aihr_practice_assignment a
JOIN pilot_params p ON p.tenant_id = a.tenant_id
WHERE a.create_time >= p.start_time AND a.create_time < p.end_time
), pilot_org AS (
SELECT DISTINCT o.ext_party_id
FROM pilot_keys k
JOIN org_identity o ON o.identity_key = k.identity_key
), formal_sessions AS (
SELECT s.*, o.ext_party_id AS formal_ext_party_id
FROM aihr_practice_session s
JOIN pilot_params p ON p.tenant_id = s.tenant_id
JOIN org_identity o ON o.identity_key = s.ext_party_id
WHERE s.mode = 'mobile' AND s.finished_time >= p.start_time AND s.finished_time < p.end_time
), completed_org AS (
SELECT formal_ext_party_id
FROM formal_sessions
GROUP BY formal_ext_party_id
HAVING COUNT(*) >= 10
), latest_calibration AS (
SELECT c.session_id, MAX(c.id) AS id
FROM aihr_practice_calibration c
JOIN formal_sessions s ON s.session_id = c.session_id
JOIN pilot_params p ON p.tenant_id = c.tenant_id
WHERE c.original_score IS NOT NULL AND c.corrected_score IS NOT NULL
AND c.create_time >= p.start_time AND c.create_time < p.end_time
GROUP BY c.session_id
), calibration_rows AS (
SELECT c.original_score, c.corrected_score
FROM aihr_practice_calibration c
JOIN latest_calibration l ON l.id = c.id
)
""";
private static final String PILOT_METRICS_SQL = PILOT_SCOPE_CTE + """
SELECT
(SELECT COUNT(*) FROM aihr_practice_scenario s JOIN pilot_params p ON p.tenant_id = s.tenant_id
WHERE s.position = '生活顾问' AND s.enabled = 1) AS scenario_count,
(SELECT COUNT(DISTINCT ext_party_id) FROM safe_org) AS org_login_count,
(SELECT COUNT(*) FROM pilot_org) AS pilot_people,
(SELECT COUNT(DISTINCT o.project_code) FROM pilot_org po JOIN safe_org o ON o.ext_party_id = po.ext_party_id
WHERE o.project_code IS NOT NULL AND o.project_code <> '') AS pilot_projects,
(SELECT COUNT(*) FROM formal_sessions) AS training_count,
(SELECT COUNT(*) FROM completed_org) AS completed_people,
(SELECT COUNT(*) FROM formal_sessions WHERE status = '已复盘') AS reviewed_count,
(SELECT COUNT(*) FROM formal_sessions WHERE total_score < 80 OR status = '待复盘') AS alert_count,
COALESCE((SELECT AVG(total_score) FROM formal_sessions), 0) AS average_score,
(SELECT COUNT(*) FROM calibration_rows) AS calibration_count,
COALESCE((SELECT SUM(CASE
WHEN (CASE WHEN original_score < 60 THEN 0 WHEN original_score < 75 THEN 1 WHEN original_score < 90 THEN 2 ELSE 3 END)
= (CASE WHEN corrected_score < 60 THEN 0 WHEN corrected_score < 75 THEN 1 WHEN corrected_score < 90 THEN 2 ELSE 3 END)
THEN 1 ELSE 0 END) FROM calibration_rows), 0) AS calibration_matched
""";
private static final String PILOT_SESSION_ROWS_SQL = PILOT_SCOPE_CTE + """
SELECT s.formal_ext_party_id,
(SELECT GROUP_CONCAT(DISTINCT o.project_code ORDER BY o.project_code SEPARATOR ';')
FROM safe_org o WHERE o.ext_party_id = s.formal_ext_party_id) AS project_codes,
s.session_id, s.trainee_name, s.scenario_name, s.total_score, s.status,
s.finished_time, s.summary, s.review_advice, s.incentive_point
FROM formal_sessions s
ORDER BY s.finished_time DESC, s.id DESC
""";
private final Map<String, ScenarioSeed> scenarios = buildScenarios();
private final ConcurrentMap<String, ActiveSession> activeSessions = new ConcurrentHashMap<>();
@@ -77,6 +194,7 @@ public class AihrPracticeSeedService {
private final JdbcTemplate jdbcTemplate;
private final AihrPracticeLlmService practiceLlmService;
private final AihrSopSeedService sopSeedService;
private final TransactionTemplate pilotExportTransaction;
private volatile boolean practiceTableReady;
private volatile boolean assignmentTableReady;
private volatile boolean scenarioTableReady;
@@ -85,11 +203,24 @@ public class AihrPracticeSeedService {
private volatile boolean annotationTableReady;
private volatile boolean audioTableReady;
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService, AihrSopSeedService sopSeedService) {
@Autowired
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
AihrSopSeedService sopSeedService, PlatformTransactionManager transactionManager) {
this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, new TransactionTemplate(transactionManager));
}
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
AihrSopSeedService sopSeedService) {
this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, (TransactionTemplate) null);
}
AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
AihrSopSeedService sopSeedService, TransactionTemplate pilotExportTransaction) {
this.objectMapper = objectMapper;
this.jdbcTemplate = jdbcTemplate;
this.practiceLlmService = practiceLlmService;
this.sopSeedService = sopSeedService;
this.pilotExportTransaction = configurePilotExportTransaction(pilotExportTransaction);
}
public List<ScenarioResponse> scenarios(String position, String scenarioType) {
@@ -515,6 +646,131 @@ public class AihrPracticeSeedService {
""", this::mapRecord, TENANT_ID, extPartyId.trim(), safeLimit);
}
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) {
TeamScope scope = teamScope(supervisorExtPartyId);
int safeLimit = normalizeTeamLimit(limit);
if (scope.scoped() && scope.extPartyIds().isEmpty()) {
return new PracticeTeamResponse(List.of(), List.of(), List.of());
}
return new PracticeTeamResponse(
practiceTeamMembers(scope),
practiceTeamRecords(scope, safeLimit),
practiceTeamAssignments(scope, safeLimit)
);
}
private List<TeamMemberResponse> practiceTeamMembers(TeamScope scope) {
if (!scope.scoped() || !orgSnapshotTableExists()) {
return practiceActivityMembers(scope);
}
boolean hasPhone = orgSnapshotColumnExists("person_phone");
String memberId = hasPhone
? "COALESCE(NULLIF(person_phone, ''), ext_party_id)"
: "ext_party_id";
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND employment_status = 'active'");
if (scope.scoped()) {
where.append(" AND (ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
if (hasPhone) {
where.append(" OR person_phone IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
where.append(")");
}
String sql = """
SELECT %s AS ext_party_id,
COALESCE(NULLIF(person_name, ''), %s) AS name
FROM aihr_org_snapshot
%s
ORDER BY position_level, person_name, ext_party_id
""".formatted(memberId, memberId, where);
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse(
rs.getString("ext_party_id"),
rs.getString("name")
), args.toArray());
}
private List<TeamMemberResponse> practiceActivityMembers(TeamScope scope) {
ensurePracticeTable();
ensureAssignmentTable();
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
String sessionScope = "";
String assignmentScope = "";
if (scope.scoped()) {
sessionScope = " AND ext_party_id IN (" + inClause(scope.extPartyIds().size()) + ")";
args.addAll(scope.extPartyIds());
}
args.add(TENANT_ID);
if (scope.scoped()) {
assignmentScope = " AND ext_party_id IN (" + inClause(scope.extPartyIds().size()) + ")";
args.addAll(scope.extPartyIds());
}
String sql = """
SELECT ext_party_id, MAX(name) AS name
FROM (
SELECT ext_party_id, trainee_name AS name
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'%s
UNION ALL
SELECT ext_party_id, trainee_name AS name
FROM aihr_practice_assignment
WHERE tenant_id = ?%s
) real_team_members
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
GROUP BY ext_party_id
ORDER BY name, ext_party_id
""".formatted(sessionScope, assignmentScope);
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse(
rs.getString("ext_party_id"),
rs.getString("name")
), args.toArray());
}
private List<RecordResponse> practiceTeamRecords(TeamScope scope, int limit) {
ensurePracticeTable();
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND mode = 'mobile'");
if (scope.scoped()) {
where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
args.add(limit);
String sql = """
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
%s
ORDER BY finished_time DESC, id DESC
LIMIT ?
""".formatted(where);
return jdbcTemplate.query(sql, this::mapRecord, args.toArray());
}
private List<PracticeAssignmentResponse> practiceTeamAssignments(TeamScope scope, int limit) {
ensureAssignmentTable();
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND source <> 'daily'");
if (scope.scoped()) {
where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
args.add(limit);
String sql = """
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, feedback, correct
FROM aihr_practice_assignment
%s
ORDER BY create_time DESC, id DESC
LIMIT ?
""".formatted(where);
return jdbcTemplate.query(sql, this::mapAssignment, args.toArray());
}
public List<RecordResponse> pendingReviewRecords(int limit) {
return pendingReviewRecords("", "", "", limit);
}
@@ -567,62 +823,108 @@ public class AihrPracticeSeedService {
return jdbcTemplate.query(sql, this::mapRecord, args.toArray());
}
public String exportPilotCsv() {
public String exportPilotCsv(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) {
throw new ServiceException("请选择完整的试点开始和结束日期");
}
if (startDate.isAfter(endDate)) {
throw new ServiceException("试点开始日期不能晚于结束日期");
}
ensurePracticeTable();
ensureAssignmentTable();
ensureCalibrationTable();
int trainingCount = count("SELECT COUNT(*) FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile'", TENANT_ID);
int completedPeople = countCompletedPilotPeople();
int pilotPeople = countPilotPeople();
boolean completionReady = pilotPeople > 0 && completedPeople * 100 >= pilotPeople * 80;
int reviewed = count("SELECT COUNT(*) FROM aihr_practice_session WHERE tenant_id = ? AND mode = 'mobile' AND status = '已复盘'", TENANT_ID);
CalibrationStats calibrationStats = calibrationStats();
AihrSopSeedService.SopReviewStats sopStats = sopSeedService.sopReviewStats();
int alertCount = count("""
SELECT COUNT(*)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND (total_score < 80 OR status = '待复盘')
""", TENANT_ID);
Double averageScore = jdbcTemplate.queryForObject("""
SELECT COALESCE(AVG(total_score), 0)
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
""", Double.class, TENANT_ID);
ensureScenarioTable();
sopSeedService.prepareSopReviewStats();
if (pilotExportTransaction == null) {
return exportPilotCsvSnapshot(startDate, endDate);
}
String csv = pilotExportTransaction.execute(status -> exportPilotCsvSnapshot(startDate, endDate));
if (csv == null) {
throw new ServiceException("正式试点指标查询无结果");
}
return csv;
}
private String exportPilotCsvSnapshot(LocalDate startDate, LocalDate endDate) {
Timestamp startTime = Timestamp.valueOf(startDate.atStartOfDay());
Timestamp endTime = Timestamp.valueOf(endDate.plusDays(1).atStartOfDay());
PilotReportMetrics metrics;
List<Map<String, Object>> sessionRows;
try {
metrics = jdbcTemplate.queryForObject(PILOT_METRICS_SQL, (rs, rowNum) -> new PilotReportMetrics(
rs.getInt("scenario_count"),
rs.getInt("org_login_count"),
rs.getInt("pilot_people"),
rs.getInt("pilot_projects"),
rs.getInt("training_count"),
rs.getInt("completed_people"),
rs.getInt("reviewed_count"),
rs.getInt("alert_count"),
rs.getDouble("average_score"),
rs.getInt("calibration_count"),
rs.getInt("calibration_matched")
), TENANT_ID, startTime, endTime);
sessionRows = jdbcTemplate.queryForList(PILOT_SESSION_ROWS_SQL, TENANT_ID, startTime, endTime);
} catch (DataAccessException e) {
throw new ServiceException("正式试点数据不可用,请先完成组织快照和二期数据表初始化");
}
if (metrics == null) {
throw new ServiceException("正式试点指标查询无结果");
}
AihrSopSeedService.SopReviewStats sopStats = sopSeedService.sopReviewStats(
startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay());
int pilotPeople = metrics.pilotPeople();
int completedPeople = metrics.completedPeople();
boolean scopeReady = metrics.orgLoginCount() >= 20
&& pilotPeople >= 20
&& metrics.pilotProjects() >= 1
&& metrics.pilotProjects() <= 2;
boolean completionReady = scopeReady && completedPeople * 100 >= pilotPeople * 80;
boolean calibrationReady = metrics.calibrationCount() >= 20
&& metrics.calibrationMatched() * 100 >= metrics.calibrationCount() * 70;
boolean sopReady = sopStats.total() > 0 && sopStats.usable() * 100 >= sopStats.total() * 80;
StringBuilder csv = new StringBuilder();
appendCsvRow(csv, "metric", "value");
appendCsvRow(csv, "training_count", trainingCount);
appendCsvRow(csv, "pilot_start_date", startDate);
appendCsvRow(csv, "pilot_end_date", endDate);
appendCsvRow(csv, "data_scope", "active_org_unique_identity");
appendCsvRow(csv, "scenario_count", metrics.scenarioCount());
appendCsvRow(csv, "scenario_count_target", 12);
appendCsvRow(csv, "formal_org_login_count", metrics.orgLoginCount());
appendCsvRow(csv, "formal_pilot_people_count", pilotPeople);
appendCsvRow(csv, "formal_pilot_people_target", 20);
appendCsvRow(csv, "formal_pilot_project_count", metrics.pilotProjects());
appendCsvRow(csv, "formal_pilot_project_target", "1-2");
appendCsvRow(csv, "formal_pilot_scope_ready", scopeReady);
appendCsvRow(csv, "training_count", metrics.trainingCount());
appendCsvRow(csv, "completion_definition", "at_least_10_sessions_per_person");
appendCsvRow(csv, "completion_people_count", completedPeople);
appendCsvRow(csv, "completion_people_total", pilotPeople);
appendCsvRow(csv, "completion_rate", percent(completedPeople, pilotPeople));
appendCsvRow(csv, "completion_rate_target", "80.0%");
appendCsvRow(csv, "completion_rate_ready", completionReady);
appendCsvRow(csv, "average_score", String.format(java.util.Locale.ROOT, "%.1f", averageScore == null ? 0 : averageScore));
appendCsvRow(csv, "alert_count", alertCount);
appendCsvRow(csv, "review_rate", percent(reviewed, trainingCount));
appendCsvRow(csv, "calibration_sample_count", calibrationStats.total());
appendCsvRow(csv, "average_score", String.format(java.util.Locale.ROOT, "%.1f", metrics.averageScore()));
appendCsvRow(csv, "alert_count", metrics.alertCount());
appendCsvRow(csv, "review_rate", percent(metrics.reviewedCount(), metrics.trainingCount()));
appendCsvRow(csv, "calibration_sample_count", metrics.calibrationCount());
appendCsvRow(csv, "ai_human_band_matched_count", metrics.calibrationMatched());
appendCsvRow(csv, "calibration_sample_target", 20);
appendCsvRow(csv, "calibration_sample_ready", calibrationStats.total() >= 20);
appendCsvRow(csv, "ai_human_band_consistency_rate", percent(calibrationStats.matched(), calibrationStats.total()));
appendCsvRow(csv, "calibration_sample_ready", metrics.calibrationCount() >= 20);
appendCsvRow(csv, "ai_human_band_consistency_rate", percent(metrics.calibrationMatched(), metrics.calibrationCount()));
appendCsvRow(csv, "ai_human_band_consistency_target", "70.0%");
appendCsvRow(csv, "ai_human_band_consistency_ready", calibrationStats.total() >= 20 && calibrationStats.matched() * 100 >= calibrationStats.total() * 70);
appendCsvRow(csv, "ai_human_band_consistency_ready", calibrationReady);
appendCsvRow(csv, "sop_review_sample_count", sopStats.total());
appendCsvRow(csv, "sop_answer_usable_count", sopStats.usable());
appendCsvRow(csv, "sop_answer_usable_rate", percent(sopStats.usable(), sopStats.total()));
appendCsvRow(csv, "sop_answer_usable_target", "80.0%");
appendCsvRow(csv, "sop_answer_usable_ready", sopStats.total() > 0 && sopStats.usable() * 100 >= sopStats.total() * 80);
appendCsvRow(csv, "pilot_metric_ready", completionReady
&& calibrationStats.total() >= 20
&& calibrationStats.matched() * 100 >= calibrationStats.total() * 70
&& sopStats.total() > 0
&& sopStats.usable() * 100 >= sopStats.total() * 80);
appendCsvRow(csv, "sop_answer_usable_ready", sopReady);
appendCsvRow(csv, "pilot_metric_ready", metrics.scenarioCount() >= 12 && completionReady && calibrationReady && sopReady);
csv.append('\n');
appendCsvRow(csv, "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point");
jdbcTemplate.queryForList("""
SELECT session_id, trainee_name, scenario_name, total_score, status, finished_time, summary, review_advice, incentive_point
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
ORDER BY finished_time DESC, id DESC
""", TENANT_ID).forEach(row -> appendCsvRow(
appendCsvRow(csv, "formal_ext_party_id", "project_codes", "session_id", "trainee", "scene", "score", "status", "finished_time", "summary", "review_advice", "incentive_point");
sessionRows.forEach(row -> appendCsvRow(
csv,
row.get("formal_ext_party_id"),
row.get("project_codes"),
row.get("session_id"),
row.get("trainee_name"),
row.get("scenario_name"),
@@ -636,6 +938,15 @@ public class AihrPracticeSeedService {
return csv.toString();
}
private static TransactionTemplate configurePilotExportTransaction(TransactionTemplate transactionTemplate) {
if (transactionTemplate == null) {
return null;
}
transactionTemplate.setReadOnly(true);
transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
return transactionTemplate;
}
private int countCompletedPilotPeople() {
ensurePracticeTable();
return count("""
@@ -686,22 +997,27 @@ public class AihrPracticeSeedService {
return detail == null || !inTeamScope(supervisorExtPartyId, reviewExtPartyId(id)) ? null : detail;
}
@Transactional(rollbackFor = Exception.class)
public boolean markReviewed(Long id) {
return markReviewed(id, null);
}
@Transactional(rollbackFor = Exception.class)
public boolean markReviewed(Long id, ReviewAdviceRequest request) {
ensurePracticeTable();
if (id == null) {
return false;
}
if (request != null && !isBlank(request.nextScenarioId())) {
ensureAssignmentTable();
}
int updated = jdbcTemplate.update("""
UPDATE aihr_practice_session
SET status = '已复盘',
review_advice = CASE WHEN ? = '' THEN review_advice ELSE ? END,
incentive_point = CASE WHEN ? IS NULL THEN incentive_point ELSE ? END,
update_time = ?
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
WHERE tenant_id = ? AND mode = 'mobile' AND id = ? AND status = '待复盘'
""",
adviceText(request),
adviceText(request),
@@ -711,7 +1027,16 @@ public class AihrPracticeSeedService {
TENANT_ID,
id
);
if (updated > 0 && request != null && !isBlank(request.nextScenarioId())) {
if (updated == 0) {
List<String> statuses = jdbcTemplate.query("""
SELECT status
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
FOR UPDATE
""", (rs, rowNum) -> rs.getString("status"), TENANT_ID, id);
return !statuses.isEmpty() && "已复盘".equals(statuses.get(0));
}
if (request != null && !isBlank(request.nextScenarioId())) {
ReviewDetailResponse detail = reviewDetail(id);
String extPartyId = reviewExtPartyId(id);
createAssignment(new PracticeAssignmentRequest(
@@ -723,9 +1048,10 @@ public class AihrPracticeSeedService {
firstNonBlank(request.nextReason(), "主管复盘后派发下一次训练")
));
}
return updated > 0;
return true;
}
@Transactional(rollbackFor = Exception.class)
public boolean markReviewed(Long id, ReviewAdviceRequest request, String supervisorExtPartyId) {
if (reviewDetail(id, supervisorExtPartyId) == null) {
return false;
@@ -1457,11 +1783,41 @@ public class AihrPracticeSeedService {
return !scope.scoped() || scope.extPartyIds().contains(extPartyId.trim());
}
public String requireSupervisorIdentity(String authenticatedIdentity) {
String identity = isBlank(authenticatedIdentity) ? "" : authenticatedIdentity.trim();
if (identity.isEmpty() || !orgSnapshotTableExists()) {
throw new ServiceException("无主管权限,无法访问主管功能");
}
boolean hasPhone = orgSnapshotColumnExists("person_phone");
List<Object> args = new ArrayList<>();
args.add(TENANT_ID);
args.add(identity);
String identityWhere = "ext_party_id = ?";
if (hasPhone) {
identityWhere = "(ext_party_id = ? OR person_phone = ?)";
args.add(identity);
}
List<String> positionLevels = jdbcTemplate.query("""
SELECT position_level
FROM aihr_org_snapshot
WHERE tenant_id = ? AND """ + identityWhere + """
AND employment_status = 'active'
LIMIT 1
""", (rs, rowNum) -> rs.getString("position_level"), args.toArray());
if (positionLevels.isEmpty() || !canSeeProject(positionLevels.get(0))) {
throw new ServiceException("无主管权限,无法访问主管功能");
}
return identity;
}
private TeamScope teamScope(String supervisorExtPartyId) {
String owner = isBlank(supervisorExtPartyId) ? "" : supervisorExtPartyId.trim();
if (owner.isEmpty() || !orgSnapshotTableExists()) {
if (owner.isEmpty()) {
return TeamScope.unscoped();
}
if (!orgSnapshotTableExists()) {
return TeamScope.scoped(List.of(owner));
}
boolean hasPhone = orgSnapshotColumnExists("person_phone");
List<Object> ownerArgs = new ArrayList<>();
ownerArgs.add(TENANT_ID);
@@ -2349,6 +2705,13 @@ public class AihrPracticeSeedService {
return Math.min(limit, 20);
}
private int normalizeTeamLimit(int limit) {
if (limit <= 0) {
return 200;
}
return Math.min(limit, 200);
}
private void ensurePracticeTable() {
if (practiceTableReady) {
return;
@@ -2880,6 +3243,21 @@ public class AihrPracticeSeedService {
private record CalibrationStats(int total, int matched) {
}
private record PilotReportMetrics(
int scenarioCount,
int orgLoginCount,
int pilotPeople,
int pilotProjects,
int trainingCount,
int completedPeople,
int reviewedCount,
int alertCount,
double averageScore,
int calibrationCount,
int calibrationMatched
) {
}
private record ResolvedTurn(String customerText, Integer emotion, Integer trust, String coachHint, boolean redFlag) {
}
@@ -66,6 +66,7 @@ import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
@@ -1865,6 +1866,21 @@ public class AihrSopSeedService {
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), TENANT_ID);
}
public void prepareSopReviewStats() {
ensureSopReviewTable();
}
public SopReviewStats sopReviewStats(LocalDateTime startTime, LocalDateTime endTime) {
ensureSopReviewTable();
return jdbcTemplate.queryForObject("""
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review
WHERE tenant_id = ? AND status = '已评审'
AND reviewed_time >= ? AND reviewed_time < ?
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")),
TENANT_ID, Timestamp.valueOf(startTime), Timestamp.valueOf(endTime));
}
private void ensureSopReviewTable() {
if (sopReviewTableReady) {
return;
@@ -1,12 +1,23 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import org.dromara.aihr.domain.AihrOrgSyncDto.SyncRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionTemplate;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
@Tag("dev")
public class AihrOrgSyncServiceTest {
@@ -26,4 +37,61 @@ public class AihrOrgSyncServiceTest {
assertFalse(AihrOrgSyncService.hasUnusablePhoneCandidate(mapper.readTree("{\"phone\":\"13900001111\"}")));
assertFalse(AihrOrgSyncService.hasUnusablePhoneCandidate(mapper.readTree("{\"name\":\"张三\"}")));
}
@Test
public void dryRunNeverTouchesSnapshotSchemaWhenExternalFetchFails() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
TransactionTemplate transactionTemplate = mock(TransactionTemplate.class);
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), jdbcTemplate, transactionTemplate);
ReflectionTestUtils.setField(service, "configuredBaseUrl", "http://127.0.0.1:1/api/open/v1");
ReflectionTestUtils.setField(service, "configuredAccessToken", "read-only-test-token");
ReflectionTestUtils.setField(service, "configuredClientId", "");
ReflectionTestUtils.setField(service, "configuredClientSecret", "");
ReflectionTestUtils.setField(service, "configuredSigningSecret", "");
assertThrows(IllegalStateException.class, () -> service.sync(
new SyncRequest(true, true, 1, 1, null, null, null)
));
verifyNoInteractions(jdbcTemplate, transactionTemplate);
}
@Test
public void successfulDryRunNeverTouchesSnapshotStorage() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/open/v1/sync/snapshot", exchange -> {
boolean employee = exchange.getRequestURI().getRawQuery().contains("resource_type=employee");
String items = employee
? "[{\"id\":\"employee-1\",\"name\":\"测试员工\",\"phone\":\"13900001111\"}]"
: "[]";
byte[] body = ("{\"data\":{\"items\":" + items + ",\"total\":" + (employee ? 1 : 0)
+ ",\"has_more\":false}}").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
TransactionTemplate transactionTemplate = mock(TransactionTemplate.class);
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), jdbcTemplate, transactionTemplate);
ReflectionTestUtils.setField(service, "configuredBaseUrl",
"http://127.0.0.1:" + server.getAddress().getPort() + "/api/open/v1");
ReflectionTestUtils.setField(service, "configuredAccessToken", "read-only-test-token");
ReflectionTestUtils.setField(service, "configuredClientId", "");
ReflectionTestUtils.setField(service, "configuredClientSecret", "");
ReflectionTestUtils.setField(service, "configuredSigningSecret", "");
var response = service.sync(new SyncRequest(true, true, 1, 1, null, null, null));
assertTrue(response.dryRun());
assertEquals(1, response.employeeCount());
assertEquals(0, response.syncedCount());
assertEquals(1, response.phoneLinked());
verifyNoInteractions(jdbcTemplate, transactionTemplate);
} finally {
server.stop(0);
}
}
}
@@ -3,6 +3,7 @@ package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -10,14 +11,28 @@ import org.mockito.ArgumentMatchers;
import org.mockito.invocation.InvocationOnMock;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -29,6 +44,48 @@ import static org.mockito.Mockito.when;
@Tag("dev")
public class AihrPracticeSeedServiceTest {
@Test
public void pilotExportRequiresExplicitOrderedWindow() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
LocalDate today = LocalDate.of(2026, 7, 10);
assertThrows(ServiceException.class, () -> service.exportPilotCsv(null, today));
assertThrows(ServiceException.class, () -> service.exportPilotCsv(today, null));
ServiceException reversed = assertThrows(ServiceException.class,
() -> service.exportPilotCsv(today, today.minusDays(1)));
assertEquals("试点开始日期不能晚于结束日期", reversed.getMessage());
}
@Test
@SuppressWarnings("unchecked")
public void pilotExportUsesReadOnlyRepeatableReadSnapshotAndFormalRows() throws Exception {
RecordingTransactionManager transactionManager = new RecordingTransactionManager();
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
PilotExportJdbcTemplate jdbcTemplate = new PilotExportJdbcTemplate(transactionManager);
AihrSopSeedService sopSeedService = new AihrSopSeedService(new ObjectMapper(), jdbcTemplate, null, "", null, null);
Constructor<AihrPracticeSeedService> constructor = (Constructor<AihrPracticeSeedService>) AihrPracticeSeedService.class
.getDeclaredConstructor(ObjectMapper.class, JdbcTemplate.class, AihrPracticeLlmService.class,
AihrSopSeedService.class, TransactionTemplate.class);
AihrPracticeSeedService service = constructor.newInstance(
new ObjectMapper(), jdbcTemplate, null, sopSeedService, transactionTemplate);
String csv = service.exportPilotCsv(LocalDate.of(2026, 7, 7), LocalDate.of(2026, 7, 10));
assertNotNull(transactionManager.definition);
assertTrue(transactionManager.definition.isReadOnly());
assertEquals(TransactionDefinition.ISOLATION_REPEATABLE_READ, transactionManager.definition.getIsolationLevel());
assertEquals(1, transactionManager.commits);
assertFalse(transactionManager.active);
assertTrue(jdbcTemplate.scenarioPrepared);
assertTrue(jdbcTemplate.sopReviewPrepared);
assertTrue(jdbcTemplate.sopQueryInTransaction);
assertTrue(jdbcTemplate.metricsSql.contains("HAVING COUNT(*) >= 10"));
assertTrue(jdbcTemplate.metricsSql.contains("JOIN formal_sessions s ON s.session_id = c.session_id"));
assertTrue(jdbcTemplate.sessionRowsSql.contains("JOIN org_identity o ON o.identity_key = s.ext_party_id"));
assertTrue(csv.contains("\"training_count\",\"1\""));
assertTrue(csv.contains("\"FORMAL-1\",\"P1\",\"session-1\""));
}
@Test
public void createAssignmentRequiresExtPartyId() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
@@ -134,6 +191,211 @@ public class AihrPracticeSeedServiceTest {
assertTrue(scopedSql.contains(condition));
}
@Test
public void supervisorIdentityAcceptsActiveSupervisorAndProjectManager() {
SupervisorAuthorizationJdbcTemplate jdbcTemplate = new SupervisorAuthorizationJdbcTemplate(Map.of(
"SUP-EXT", "主管",
"13900001111", "项目经理"
));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
assertEquals("SUP-EXT", service.requireSupervisorIdentity(" SUP-EXT "));
assertEquals("13900001111", service.requireSupervisorIdentity("13900001111"));
assertTrue(jdbcTemplate.authorizationSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.authorizationSql.contains("employment_status = 'active'"));
assertTrue(jdbcTemplate.authorizationSql.contains("ext_party_id = ? OR person_phone = ?"));
assertEquals(List.of("000000", "13900001111", "13900001111"), jdbcTemplate.authorizationArgs);
}
@Test
public void supervisorIdentityRejectsFrontlineUnknownAndBlankIdentity() {
SupervisorAuthorizationJdbcTemplate jdbcTemplate = new SupervisorAuthorizationJdbcTemplate(Map.of(
"EMP-1", "一线"
));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
ServiceException frontline = assertThrows(ServiceException.class,
() -> service.requireSupervisorIdentity("EMP-1"));
ServiceException unknown = assertThrows(ServiceException.class,
() -> service.requireSupervisorIdentity("UNKNOWN"));
ServiceException blank = assertThrows(ServiceException.class,
() -> service.requireSupervisorIdentity(" "));
assertTrue(frontline.getMessage().contains("无主管权限"));
assertTrue(unknown.getMessage().contains("无主管权限"));
assertTrue(blank.getMessage().contains("无主管权限"));
assertEquals(2, jdbcTemplate.authorizationQueryCount);
}
@Test
public void markReviewedOnlyTransitionsPendingReview() {
MarkReviewedJdbcTemplate jdbcTemplate = new MarkReviewedJdbcTemplate(1, null);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
assertTrue(service.markReviewed(42L));
assertTrue(jdbcTemplate.sessionUpdateSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.sessionUpdateSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.sessionUpdateSql.contains("id = ?"));
assertTrue(jdbcTemplate.sessionUpdateSql.contains("status = '待复盘'"));
assertEquals(1, jdbcTemplate.sessionUpdateCount);
}
@Test
public void markReviewedTreatsAlreadyReviewedAsSuccessWithoutAssignment() {
MarkReviewedJdbcTemplate jdbcTemplate = new MarkReviewedJdbcTemplate(0, "已复盘");
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
ReviewAdviceRequest request = new ReviewAdviceRequest(
"主管",
"重复提交不应覆盖",
10,
"complaint-water",
"重复提交不应派发"
);
assertTrue(service.markReviewed(42L, request));
assertTrue(jdbcTemplate.lockedStatusSql.contains("SELECT status"));
assertTrue(jdbcTemplate.lockedStatusSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.lockedStatusSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.lockedStatusSql.contains("id = ?"));
assertTrue(jdbcTemplate.lockedStatusSql.contains("FOR UPDATE"));
assertEquals(List.of("000000", 42L), jdbcTemplate.lockedStatusArgs);
assertEquals(1, jdbcTemplate.sessionUpdateCount);
assertEquals(0, jdbcTemplate.assignmentInsertCount);
}
@Test
public void markReviewedReturnsFalseWhenRecordIsNeitherPendingNorReviewed() {
MarkReviewedJdbcTemplate jdbcTemplate = new MarkReviewedJdbcTemplate(0, "训练完成");
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
assertFalse(service.markReviewed(404L));
assertEquals(0, jdbcTemplate.assignmentInsertCount);
}
@Test
public void markReviewedExternalOverloadsAreTransactional() throws NoSuchMethodException {
assertTransactional(AihrPracticeSeedService.class.getMethod(
"markReviewed", Long.class, ReviewAdviceRequest.class));
assertTransactional(AihrPracticeSeedService.class.getMethod(
"markReviewed", Long.class, ReviewAdviceRequest.class, String.class));
}
@Test
public void practiceTeamSnapshotUsesActiveScopedMembersAndAllStatusRows() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
Object snapshot = invokePracticeTeamSnapshot(service, "SUP-PHONE", 200);
List<?> members = snapshotItems(snapshot, "members");
List<?> records = snapshotItems(snapshot, "records");
List<?> assignments = snapshotItems(snapshot, "assignments");
assertEquals(1, members.size());
assertEquals("稳定员工", recordValue(members.get(0), "name"));
assertEquals("已复盘", recordValue(records.get(0), "status"));
assertEquals("已完成", recordValue(assignments.get(0), "status"));
assertTrue(jdbcTemplate.membersSql.contains("employment_status = 'active'"));
assertTrue(jdbcTemplate.membersSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?,?,?,?)"));
assertTrue(jdbcTemplate.recordsSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.recordsSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?,?,?,?)"));
assertFalse(jdbcTemplate.recordsSql.contains("status = '待复盘'"));
assertTrue(jdbcTemplate.recordsSql.contains("ORDER BY finished_time DESC"));
assertEquals(List.of("000000", "EMP-SUP", "EMP-1", "SUP-PHONE", "EMP-PHONE-1", 200), jdbcTemplate.recordsArgs);
assertTrue(jdbcTemplate.assignmentsSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.assignmentsSql.contains("ext_party_id IN (?,?,?,?)"));
assertTrue(jdbcTemplate.assignmentsSql.contains("source <> 'daily'"));
assertTrue(jdbcTemplate.assignmentsSql.indexOf("source <> 'daily'")
< jdbcTemplate.assignmentsSql.indexOf("LIMIT ?"));
assertFalse(jdbcTemplate.assignmentsSql.contains("status = '待训练'"));
assertEquals(List.of("000000", "EMP-SUP", "EMP-1", "SUP-PHONE", "EMP-PHONE-1", 200), jdbcTemplate.assignmentsArgs);
}
@Test
public void markReviewedInitializesAssignmentTableBeforeSessionUpdate() {
ReviewAssignmentOrderJdbcTemplate jdbcTemplate = new ReviewAssignmentOrderJdbcTemplate();
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
ReviewAdviceRequest request = new ReviewAdviceRequest(
"主管",
"需要补练",
null,
"complaint-water",
"复盘后专项"
);
assertTrue(service.markReviewed(42L, request));
int sessionUpdate = jdbcTemplate.events.indexOf("session-update");
int assignmentInsert = jdbcTemplate.events.indexOf("assignment-insert");
int lastAssignmentDdl = jdbcTemplate.events.lastIndexOf("assignment-ddl");
assertTrue(lastAssignmentDdl >= 0);
assertTrue(lastAssignmentDdl < sessionUpdate, jdbcTemplate.events.toString());
assertTrue(sessionUpdate < assignmentInsert, jdbcTemplate.events.toString());
}
@Test
public void supervisorControllerEndpointsRequireValidatedSupervisorScope() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/controller/AihrMobileController.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java");
}
String controllerSource = Files.readString(source);
assertTrue(controllerSource.contains("mobileSeedService.practiceReviews(supervisorScopeExtPartyId(),"));
assertTrue(controllerSource.contains("mobileSeedService.practiceTeam(supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.practiceReview(id, supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.markReviewed(id, request, supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.practiceAlerts(supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("private String supervisorScopeExtPartyId()"));
assertTrue(controllerSource.contains("mobileSeedService.requireSupervisorIdentity(currentAppUsername())"));
assertFalse(controllerSource.contains("private static String supervisorScopeExtPartyId()"));
assertFalse(controllerSource.contains("supervisorScopeExtPartyId() {\n return \"\";"));
assertFalse(controllerSource.contains("supervisor H5 keeps team-scope"));
}
@Test
public void practiceTeamSnapshotUsesActivityMembersWhenUnscopedWithOrgSnapshot() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
Object snapshot = invokePracticeTeamSnapshot(service, "", 200);
List<?> members = snapshotItems(snapshot, "members");
assertEquals(1, members.size());
assertEquals("真实训练员工", recordValue(members.get(0), "name"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_session"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_assignment"));
assertFalse(jdbcTemplate.membersSql.contains("FROM aihr_org_snapshot"));
assertEquals(List.of("000000", "000000"), jdbcTemplate.membersArgs);
}
@Test
public void practiceTeamSnapshotScopesRealFallbackWhenOrgSnapshotIsUnavailable() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(false);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
Object snapshot = invokePracticeTeamSnapshot(service, "SUP-PHONE", 200);
List<?> members = snapshotItems(snapshot, "members");
assertEquals(1, members.size());
assertEquals("真实训练员工", recordValue(members.get(0), "name"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_session"));
assertTrue(jdbcTemplate.membersSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_assignment"));
assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?)"));
assertEquals(List.of("000000", "SUP-PHONE", "000000", "SUP-PHONE"), jdbcTemplate.membersArgs);
assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?)"));
assertEquals(List.of("000000", "SUP-PHONE", 200), jdbcTemplate.recordsArgs);
assertTrue(jdbcTemplate.assignmentsSql.contains("ext_party_id IN (?)"));
assertEquals(List.of("000000", "SUP-PHONE", 200), jdbcTemplate.assignmentsArgs);
}
@Test
public void competencyDoesNotSeedScoreForEmptyEmployee() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
@@ -208,6 +470,7 @@ public class AihrPracticeSeedServiceTest {
try {
int intValue = Integer.parseInt(entry.getValue());
when(resultSet.getInt(entry.getKey())).thenReturn(intValue);
when(resultSet.getLong(entry.getKey())).thenReturn((long) intValue);
} catch (NumberFormatException ignored) {
// String-only test column.
}
@@ -217,6 +480,127 @@ public class AihrPracticeSeedServiceTest {
return mapped;
}
private static void assertTransactional(Method method) {
Transactional annotation = method.getAnnotation(Transactional.class);
assertTrue(annotation != null, method + " must be transactional");
assertTrue(List.of(annotation.rollbackFor()).contains(Exception.class));
}
private static Object invokePracticeTeamSnapshot(AihrPracticeSeedService service, String supervisorExtPartyId, int limit)
throws Exception {
Method method = AihrPracticeSeedService.class.getMethod("practiceTeamSnapshot", String.class, int.class);
return method.invoke(service, supervisorExtPartyId, limit);
}
@SuppressWarnings("unchecked")
private static List<?> snapshotItems(Object snapshot, String accessor) throws Exception {
return (List<?>) snapshot.getClass().getMethod(accessor).invoke(snapshot);
}
private static Object recordValue(Object record, String accessor) throws Exception {
return record.getClass().getMethod(accessor).invoke(record);
}
private static final class RecordingTransactionManager implements PlatformTransactionManager {
private TransactionDefinition definition;
private int starts;
private int commits;
private boolean active;
@Override
public TransactionStatus getTransaction(TransactionDefinition definition) {
this.definition = definition;
starts += 1;
active = true;
return new SimpleTransactionStatus();
}
@Override
public void commit(TransactionStatus status) {
assertTrue(active);
commits += 1;
active = false;
}
@Override
public void rollback(TransactionStatus status) {
active = false;
throw new AssertionError("pilot export should not roll back");
}
}
private static final class PilotExportJdbcTemplate extends JdbcTemplate {
private final RecordingTransactionManager transactionManager;
private String metricsSql = "";
private String sessionRowsSql = "";
private boolean scenarioPrepared;
private boolean sopReviewPrepared;
private boolean sopQueryInTransaction;
private PilotExportJdbcTemplate(RecordingTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
@Override
public void execute(String sql) {
assertFalse(transactionManager.active);
if (sql.contains("aihr_practice_scenario")) {
scenarioPrepared = true;
}
if (sql.contains("aihr_sop_answer_review")) {
sopReviewPrepared = true;
}
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (requiredType == Integer.class) {
return requiredType.cast(1);
}
return null;
}
@Override
public <T> T queryForObject(String sql, RowMapper<T> rowMapper, Object... args) {
assertTrue(transactionManager.active);
assertTrue(scenarioPrepared);
if (sql.contains("aihr_sop_answer_review")) {
assertTrue(sopReviewPrepared);
sopQueryInTransaction = true;
} else {
metricsSql = sql;
}
try {
ResultSet resultSet = mock(ResultSet.class);
when(resultSet.getInt(ArgumentMatchers.anyString())).thenReturn(1);
when(resultSet.getDouble("average_score")).thenReturn(86.0d);
return rowMapper.mapRow(resultSet, 0);
} catch (SQLException e) {
throw new AssertionError(e);
}
}
@Override
public List<Map<String, Object>> queryForList(String sql, Object... args) {
assertTrue(transactionManager.active);
assertTrue(scenarioPrepared);
sessionRowsSql = sql;
return List.of(Map.ofEntries(
Map.entry("formal_ext_party_id", "FORMAL-1"),
Map.entry("project_codes", "P1"),
Map.entry("session_id", "session-1"),
Map.entry("trainee_name", "员工甲"),
Map.entry("scenario_name", "投诉处理"),
Map.entry("total_score", 86),
Map.entry("status", "已复盘"),
Map.entry("finished_time", Timestamp.valueOf("2026-07-08 10:00:00")),
Map.entry("summary", "正式试点记录"),
Map.entry("review_advice", "保持回访"),
Map.entry("incentive_point", 3)
));
}
}
private static final class AssignmentJdbcTemplate extends JdbcTemplate {
private final String ownerLevel;
private final List<String> scopeIds;
@@ -326,4 +710,265 @@ public class AihrPracticeSeedServiceTest {
}
}
private static final class MarkReviewedJdbcTemplate extends JdbcTemplate {
private final int transitionResult;
private final String lockedStatus;
private String sessionUpdateSql = "";
private int sessionUpdateCount;
private String lockedStatusSql = "";
private List<Object> lockedStatusArgs = List.of();
private int assignmentInsertCount;
private MarkReviewedJdbcTemplate(int transitionResult, String lockedStatus) {
this.transitionResult = transitionResult;
this.lockedStatus = lockedStatus;
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
return requiredType.cast(1);
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (!sql.contains("FOR UPDATE")) {
return List.of();
}
lockedStatusSql = sql;
lockedStatusArgs = List.of(args);
if (lockedStatus == null) {
return List.of();
}
try {
return mapRows(rowMapper, List.of(Map.of("status", lockedStatus)));
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("UPDATE aihr_practice_session")) {
sessionUpdateSql = sql;
sessionUpdateCount += 1;
return transitionResult;
}
if (sql.contains("INSERT INTO aihr_practice_assignment")) {
assignmentInsertCount += 1;
return 1;
}
return 0;
}
@Override
public void execute(String sql) {
// Schema setup is irrelevant for transition tests.
}
}
private static final class SupervisorAuthorizationJdbcTemplate extends JdbcTemplate {
private final Map<String, String> positionLevels;
private String authorizationSql = "";
private List<Object> authorizationArgs = List.of();
private int authorizationQueryCount;
private SupervisorAuthorizationJdbcTemplate(Map<String, String> positionLevels) {
this.positionLevels = positionLevels;
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
return requiredType.cast(1);
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
authorizationSql = sql;
authorizationArgs = List.of(args);
authorizationQueryCount += 1;
String positionLevel = positionLevels.get(String.valueOf(args[1]));
if (positionLevel == null) {
return List.of();
}
try {
return mapRows(rowMapper, List.of(Map.of("position_level", positionLevel)));
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
private static final class TeamSnapshotJdbcTemplate extends JdbcTemplate {
private final boolean orgSnapshotAvailable;
private String membersSql = "";
private List<Object> membersArgs = List.of();
private String recordsSql = "";
private List<Object> recordsArgs = List.of();
private String assignmentsSql = "";
private List<Object> assignmentsArgs = List.of();
private TeamSnapshotJdbcTemplate(boolean orgSnapshotAvailable) {
this.orgSnapshotAvailable = orgSnapshotAvailable;
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
if (sql.contains("information_schema.TABLES") && sql.contains("aihr_org_snapshot")) {
return requiredType.cast(orgSnapshotAvailable ? 1 : 0);
}
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (sql.contains("information_schema.COLUMNS") && sql.contains("aihr_org_snapshot")) {
return requiredType.cast(orgSnapshotAvailable ? 1 : 0);
}
return requiredType.cast(1);
}
@Override
public void execute(String sql) {
// Schema setup is irrelevant for snapshot query tests.
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
try {
if (sql.contains("SELECT project_code, position_level")) {
return mapRows(rowMapper, List.of(Map.of(
"project_code", "P1",
"position_level", "主管"
)));
}
if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")) {
return mapRows(rowMapper, List.of(
Map.of("ext_party_id", "EMP-SUP"),
Map.of("ext_party_id", "EMP-1")
));
}
if (sql.contains("SELECT person_phone") && sql.contains("ORDER BY position_level")) {
return mapRows(rowMapper, List.of(
Map.of("person_phone", "SUP-PHONE"),
Map.of("person_phone", "EMP-PHONE-1")
));
}
if (sql.contains("FROM aihr_org_snapshot") && sql.contains("AS name")) {
membersSql = sql;
membersArgs = List.of(args);
return mapRows(rowMapper, List.of(Map.of(
"ext_party_id", "EMP-PHONE-1",
"name", "稳定员工"
)));
}
if (sql.contains("UNION ALL")
&& sql.contains("FROM aihr_practice_session")
&& sql.contains("FROM aihr_practice_assignment")) {
membersSql = sql;
membersArgs = List.of(args);
return mapRows(rowMapper, List.of(Map.of(
"ext_party_id", sql.contains("ext_party_id IN") ? "SUP-PHONE" : "REAL-1",
"name", "真实训练员工"
)));
}
if (sql.contains("SELECT id, session_id") && sql.contains("FROM aihr_practice_session")) {
recordsSql = sql;
recordsArgs = List.of(args);
String extPartyId = sql.contains("ext_party_id IN")
? "SUP-PHONE"
: orgSnapshotAvailable ? "EMP-PHONE-1" : "REAL-1";
return mapRows(rowMapper, List.of(Map.of(
"id", "1",
"session_id", "session-1",
"trainee_name", "稳定员工",
"scenario_name", "新高分场景",
"total_score", "93",
"status", "已复盘",
"summary", "已完成复盘",
"ext_party_id", extPartyId
)));
}
if (sql.contains("SELECT id, ext_party_id") && sql.contains("FROM aihr_practice_assignment")) {
assignmentsSql = sql;
assignmentsArgs = List.of(args);
String extPartyId = sql.contains("ext_party_id IN")
? "SUP-PHONE"
: orgSnapshotAvailable ? "EMP-PHONE-1" : "REAL-1";
return mapRows(rowMapper, List.of(Map.of(
"id", "2",
"ext_party_id", extPartyId,
"trainee_name", orgSnapshotAvailable ? "稳定员工" : "真实训练员工",
"scenario_id", "complaint-water",
"scenario_name", "投诉接待",
"source", "manual",
"reason", "历史专项",
"status", "已完成"
)));
}
return List.of();
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
private static final class ReviewAssignmentOrderJdbcTemplate extends JdbcTemplate {
private final List<String> events = new ArrayList<>();
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
if (sql.contains("TABLE_NAME = 'aihr_practice_assignment'")) {
return requiredType.cast(0);
}
return requiredType.cast(1);
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("UPDATE aihr_practice_session")) {
events.add("session-update");
return 1;
}
if (sql.contains("INSERT INTO aihr_practice_assignment")) {
events.add("assignment-insert");
return 1;
}
return 0;
}
@Override
public void execute(String sql) {
if (sql.contains("aihr_practice_assignment")) {
events.add("assignment-ddl");
}
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
try {
if (sql.contains("SELECT ext_party_id") && sql.contains("FROM aihr_practice_session")) {
return mapRows(rowMapper, List.of(Map.of("ext_party_id", "EMP-1")));
}
return List.of();
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
}