feat(aihr): complete phase two pilot readiness
This commit is contained in:
+9
-5
@@ -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() {
|
||||
|
||||
+8
-3
@@ -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);
|
||||
}
|
||||
|
||||
+10
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+3
-1
@@ -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<>();
|
||||
|
||||
+422
-44
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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;
|
||||
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+645
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user