fix(aihr): separate onboarding and candidate review states

This commit is contained in:
2026-07-15 02:39:29 +08:00
parent 21a13d806c
commit 40574c204d
13 changed files with 487 additions and 13 deletions
@@ -14,7 +14,9 @@ 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.OnboardTaskResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
@@ -198,6 +200,16 @@ public class AihrMobileController {
return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId), currentAppUsername()));
}
@GetMapping("/onboard/tasks")
public R<List<OnboardTaskResponse>> onboardTasks() {
return R.ok(mobileSeedService.onboardTasks(ownMobileExtPartyId(null)));
}
@GetMapping("/qualification")
public R<QualificationResponse> qualification() {
return R.ok(mobileSeedService.qualification(ownMobileExtPartyId(null)));
}
@PostMapping("/practice/assignments")
public R<PracticeAssignmentResponse> createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) {
return R.ok(mobileSeedService.createPracticeAssignment(request, supervisorScopeExtPartyId()));
@@ -277,6 +277,42 @@ public final class AihrPracticeDto {
) {
}
public record OnboardTaskResponse(
Long id,
String projectCode,
String positionCode,
Long sopId,
Long courseId,
String taskType,
String status,
String assignBy,
String assignedTime,
String dueTime,
String completedTime,
String evidenceRef
) {
}
public record QualificationGateResponse(
Long id,
String projectCode,
String positionCode,
String certId,
Boolean passed,
String validThru,
String evidenceRef,
String reviewer,
String reviewedTime
) {
}
public record QualificationResponse(
String status,
String positionCode,
List<QualificationGateResponse> gates
) {
}
public record TeamMemberResponse(String extPartyId, String name) {
}
@@ -8,6 +8,8 @@ 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.OnboardTaskResponse;
import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
@@ -166,6 +168,14 @@ public class AihrMobileSeedService {
return practiceSeedService.assignments(extPartyId, supervisorExtPartyId, 10);
}
public List<OnboardTaskResponse> onboardTasks(String extPartyId) {
return practiceSeedService.onboardTasks(extPartyId, 20);
}
public QualificationResponse qualification(String extPartyId) {
return practiceSeedService.qualification(extPartyId);
}
public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request) {
return practiceSeedService.createAssignment(request);
}
@@ -26,6 +26,9 @@ 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;
import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse;
import org.dromara.aihr.domain.AihrPracticeDto.QualificationGateResponse;
import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse;
import org.dromara.aihr.domain.AihrPracticeDto.MistakeAggregateResponse;
@@ -2036,6 +2039,96 @@ public class AihrPracticeSeedService {
""".formatted(nameExpression), this::mapAssignment, tenantId(), party, party, normalizeLimit(limit));
}
/**
* Formal onboarding tasks are read-only in phase one. The employee identity is
* resolved from the authenticated APP identity to the external organization ID;
* no task is created from this endpoint.
*/
public List<OnboardTaskResponse> onboardTasks(String extPartyId, int limit) {
String identity = isBlank(extPartyId) ? "" : extPartyId.trim();
if (identity.isEmpty() || !formalPositionTablesExist("aihr_onboard_task")) {
return List.of();
}
boolean hasOrgSnapshot = orgSnapshotTableExists() && orgSnapshotColumnExists("person_phone");
String identityPredicate = hasOrgSnapshot
? "(t.ext_party_id = ? OR t.ext_party_id IN (SELECT o.ext_party_id FROM aihr_org_snapshot o WHERE o.tenant_id = ? AND o.employment_status = 'active' AND (o.ext_party_id = ? OR o.person_phone = ?)))"
: "t.ext_party_id = ?";
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.add(identity);
if (hasOrgSnapshot) {
args.add(tenantId());
args.add(identity);
args.add(identity);
}
args.add(normalizeLimit(limit));
return jdbcTemplate.query("""
SELECT t.id, t.project_code, t.position_code, t.sop_id, t.course_id, t.task_type,
t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref
FROM aihr_onboard_task t
WHERE t.tenant_id = ? AND %s
ORDER BY CASE t.status WHEN '待完成' THEN 0 WHEN '进行中' THEN 1 WHEN '已完成' THEN 2 ELSE 3 END,
COALESCE(t.due_time, t.create_time) ASC, t.id DESC
LIMIT ?
""".formatted(identityPredicate), (rs, rowNum) -> new OnboardTaskResponse(
rs.getLong("id"),
rs.getString("project_code"),
rs.getString("position_code"),
rs.getObject("sop_id") == null ? null : rs.getLong("sop_id"),
rs.getObject("course_id") == null ? null : rs.getLong("course_id"),
rs.getString("task_type"),
rs.getString("status"),
rs.getString("assign_by"),
formatTimestamp(rs.getTimestamp("assigned_time")),
formatTimestamp(rs.getTimestamp("due_time")),
formatTimestamp(rs.getTimestamp("completed_time")),
rs.getString("evidence_ref")
), args.toArray());
}
/**
* Return qualification evidence for the current APP user without treating an
* empty rules table as a pass. Formal qualification remains NOT_CONFIGURED until
* HR supplies a gate row and its evidence.
*/
public QualificationResponse qualification(String extPartyId) {
String identity = isBlank(extPartyId) ? "" : extPartyId.trim();
if (identity.isEmpty() || !formalPositionTablesExist("aihr_qualification_gate")) {
return new QualificationResponse("NOT_CONFIGURED", "", List.of());
}
boolean hasOrgSnapshot = orgSnapshotTableExists() && orgSnapshotColumnExists("person_phone");
String identityPredicate = hasOrgSnapshot
? "(q.ext_party_id = ? OR q.ext_party_id IN (SELECT o.ext_party_id FROM aihr_org_snapshot o WHERE o.tenant_id = ? AND o.employment_status = 'active' AND (o.ext_party_id = ? OR o.person_phone = ?)))"
: "q.ext_party_id = ?";
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.add(identity);
if (hasOrgSnapshot) {
args.add(tenantId());
args.add(identity);
args.add(identity);
}
List<QualificationGateResponse> gates = jdbcTemplate.query("""
SELECT q.id, q.project_code, q.position_code, q.cert_id, q.passed, q.valid_thru,
q.evidence_ref, q.reviewer, q.reviewed_time
FROM aihr_qualification_gate q
WHERE q.tenant_id = ? AND %s
ORDER BY q.position_code, q.cert_id, q.id DESC
""".formatted(identityPredicate), (rs, rowNum) -> new QualificationGateResponse(
rs.getLong("id"),
rs.getString("project_code"),
rs.getString("position_code"),
rs.getString("cert_id"),
rs.getBoolean("passed"),
rs.getDate("valid_thru") == null ? null : rs.getDate("valid_thru").toLocalDate().toString(),
rs.getString("evidence_ref"),
rs.getString("reviewer"),
formatTimestamp(rs.getTimestamp("reviewed_time"))
), args.toArray());
return new QualificationResponse(qualificationStatus(gates),
gates.isEmpty() ? "" : firstNonBlank(gates.get(0).positionCode(), ""), gates);
}
public List<PracticeAssignmentResponse> assignments(String extPartyId, String supervisorExtPartyId, int limit) {
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
String supervisor = isBlank(supervisorExtPartyId) ? "" : supervisorExtPartyId.trim();
@@ -3779,6 +3872,43 @@ public class AihrPracticeSeedService {
return Math.min(limit, 200);
}
private String formatTimestamp(Timestamp value) {
return value == null ? "" : value.toLocalDateTime().format(TIME_FORMATTER);
}
private String qualificationStatus(List<QualificationGateResponse> gates) {
if (gates.isEmpty()) {
return "NOT_CONFIGURED";
}
if (gates.stream().anyMatch(gate -> !Boolean.TRUE.equals(gate.passed()))) {
return "BLOCKED";
}
LocalDate today = LocalDate.now();
if (gates.stream().anyMatch(gate -> gate.validThru() != null
&& !gate.validThru().isBlank()
&& LocalDate.parse(gate.validThru()).isBefore(today))) {
return "EXPIRED";
}
return "PASSED";
}
private boolean formalPositionTablesExist(String tableName) {
if (!Set.of("aihr_onboard_task", "aihr_qualification_gate").contains(tableName)) {
return false;
}
try {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
""", Integer.class, tableName);
return count != null && count > 0;
} catch (DataAccessException ex) {
log.warn("formal position table check failed for {}, returning empty result", tableName);
return false;
}
}
private void ensurePracticeTable() {
if (practiceTableReady) {
return;
@@ -659,6 +659,23 @@ public class AihrPracticeSeedServiceTest {
assertFalse(controllerSource.contains("supervisor H5 keeps team-scope"));
}
@Test
public void formalOnboardingReadsAreLoginScopedAndReadOnly() 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("@SaCheckLogin\npublic class AihrMobileController"));
assertTrue(controllerSource.contains("@GetMapping(\"/onboard/tasks\")"));
assertTrue(controllerSource.contains("@GetMapping(\"/qualification\")"));
assertTrue(controllerSource.contains("mobileSeedService.onboardTasks(ownMobileExtPartyId(null))"));
assertTrue(controllerSource.contains("mobileSeedService.qualification(ownMobileExtPartyId(null))"));
assertFalse(controllerSource.contains("@PostMapping(\"/onboard/tasks\")"));
assertFalse(controllerSource.contains("@PostMapping(\"/qualification\")"));
}
@Test
public void dashboardControllerRequiresHrRole() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/controller/AihrDashboardController.java");