feat(election): comprehensive audit log — rich details for all actions
- Fixed VARCHAR(500) overflow: description column changed to TEXT (Liquibase 0346)
- Fixed CEO pre-check 500 error: fm.firstname/lastname column name fix
- Fixed audit log showing wrong PASS/FAIL for CEO pre-check
- Fixed approval action not showing step name/number (read PENDING step before action)
- submitNomination: details JSON with applicantName, memberId, postId, gender, caste
- runEligibilityCheck: details JSON with all 5 check results + remarks
- runCeoPreCheck: details JSON with all 7 individual check results + remarks
- processApprovalStep: details JSON with action, stepName, stepNumber, remarks, rejectionReason
- activateElection/closeNominations/openVoting/closeVoting/cancelElection: details with electionTitle + newStatus
- updateElection: full request JSON via ObjectMapper
- approveCandidate: details with nominationId + applicantName
- updateCandidate: details with symbolUpdated, profileUpdated, briefProfile
- uploadCandidateSymbol: details with filename + mimeType
- declareResults: winner summary JSON array [{post, winner, votes}]
- saveManualVoteCounts: return type changed void→String, details JSON array with candidate vote data
- Added Liquibase migrations: 0344, 0345 for manual vote count, 0346 for TEXT column
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
99a89d5d81
commit
99cc972804
+37
-6
@@ -18,6 +18,7 @@ import jakarta.ws.rs.core.Response;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
|
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.election.data.ElectionNominationData;
|
||||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
||||||
import org.apache.fineract.portfolio.election.service.ElectionAnalyticsService;
|
import org.apache.fineract.portfolio.election.service.ElectionAnalyticsService;
|
||||||
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
||||||
@@ -68,9 +69,21 @@ public class ElectionCandidateApiResource {
|
|||||||
@PathParam("nominationId") Long nominationId,
|
@PathParam("nominationId") Long nominationId,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
Long approvedBy = context.authenticatedUser().getId();
|
Long approvedBy = context.authenticatedUser().getId();
|
||||||
Long candidateId = writeService.approveCandidate(nominationId, approvedBy);
|
ElectionNominationData nom = readService.retrieveNomination(nominationId);
|
||||||
|
String applicantName = nom != null ? nom.getApplicantName() : null;
|
||||||
|
Long candidateId;
|
||||||
|
try {
|
||||||
|
candidateId = writeService.approveCandidate(nominationId, approvedBy);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.APPROVE_CANDIDATE_FAILED,
|
||||||
|
"CANDIDATE", nominationId, electionId,
|
||||||
|
"Candidate approval failed for nomination " + nominationId + ": " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
String appDet = "{\"nominationId\":" + nominationId + ",\"applicantName\":" + esc(applicantName) + "}";
|
||||||
electionAuditService.log(ElectionAuditAction.APPROVE_CANDIDATE, "CANDIDATE", candidateId, electionId,
|
electionAuditService.log(ElectionAuditAction.APPROVE_CANDIDATE, "CANDIDATE", candidateId, electionId,
|
||||||
"Nomination approved as candidate", null, clientIp(httpRequest));
|
"Nomination #" + nominationId + " approved — " + (applicantName != null ? applicantName : "candidate #" + candidateId),
|
||||||
|
appDet, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", candidateId)).build();
|
return Response.ok(Map.of("resourceId", candidateId)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,9 +94,14 @@ public class ElectionCandidateApiResource {
|
|||||||
@PathParam("candidateId") Long candidateId,
|
@PathParam("candidateId") Long candidateId,
|
||||||
Map<String, String> body,
|
Map<String, String> body,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.updateCandidate(candidateId, body.get("symbol"), body.get("briefProfile"));
|
String symbol = body.get("symbol");
|
||||||
|
String briefProfile = body.get("briefProfile");
|
||||||
|
writeService.updateCandidate(candidateId, symbol, briefProfile);
|
||||||
|
String updatedFields = (symbol != null ? "symbol" : "") + (symbol != null && briefProfile != null ? ", " : "") + (briefProfile != null ? "profile" : "");
|
||||||
|
String updDet = "{\"symbolUpdated\":" + (symbol != null) + ",\"profileUpdated\":" + (briefProfile != null)
|
||||||
|
+ (briefProfile != null ? ",\"briefProfile\":" + esc(briefProfile) : "") + "}";
|
||||||
electionAuditService.log(ElectionAuditAction.UPDATE_CANDIDATE, "CANDIDATE", candidateId, electionId,
|
electionAuditService.log(ElectionAuditAction.UPDATE_CANDIDATE, "CANDIDATE", candidateId, electionId,
|
||||||
"Candidate updated", null, clientIp(httpRequest));
|
"Candidate updated" + (!updatedFields.isBlank() ? ": " + updatedFields : ""), updDet, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", candidateId)).build();
|
return Response.ok(Map.of("resourceId", candidateId)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,8 +136,10 @@ public class ElectionCandidateApiResource {
|
|||||||
// use the returned (sanitized/normalized) path, not the pre-computed one
|
// use the returned (sanitized/normalized) path, not the pre-computed one
|
||||||
String savedPath = contentStoreService.upload(storagePath, fileStream, mimeType);
|
String savedPath = contentStoreService.upload(storagePath, fileStream, mimeType);
|
||||||
writeService.uploadCandidateSymbol(candidateId, mimeType + "|" + savedPath);
|
writeService.uploadCandidateSymbol(candidateId, mimeType + "|" + savedPath);
|
||||||
|
String filename = fileDetail != null ? fileDetail.getFileName() : null;
|
||||||
|
String symDet = "{\"filename\":" + esc(filename) + ",\"mimeType\":" + esc(mimeType) + "}";
|
||||||
electionAuditService.log(ElectionAuditAction.UPLOAD_SYMBOL, "CANDIDATE", candidateId, electionId,
|
electionAuditService.log(ElectionAuditAction.UPLOAD_SYMBOL, "CANDIDATE", candidateId, electionId,
|
||||||
"Election symbol uploaded", null, clientIp(httpRequest));
|
"Election symbol uploaded" + (filename != null ? ": " + filename : ""), symDet, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", candidateId)).build();
|
return Response.ok(Map.of("resourceId", candidateId)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +184,13 @@ public class ElectionCandidateApiResource {
|
|||||||
@PathParam("candidateId") Long candidateId,
|
@PathParam("candidateId") Long candidateId,
|
||||||
@QueryParam("reason") String reason,
|
@QueryParam("reason") String reason,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.removeCandidate(candidateId, reason);
|
try {
|
||||||
|
writeService.removeCandidate(candidateId, reason);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.REMOVE_CANDIDATE_FAILED,
|
||||||
|
"CANDIDATE", candidateId, electionId, "Remove candidate failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
electionAuditService.log(ElectionAuditAction.REMOVE_CANDIDATE, "CANDIDATE", candidateId, electionId,
|
electionAuditService.log(ElectionAuditAction.REMOVE_CANDIDATE, "CANDIDATE", candidateId, electionId,
|
||||||
"Candidate removed: " + reason, null, clientIp(httpRequest));
|
"Candidate removed: " + reason, null, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", candidateId, "status", "REMOVED")).build();
|
return Response.ok(Map.of("resourceId", candidateId, "status", "REMOVED")).build();
|
||||||
@@ -195,4 +221,9 @@ public class ElectionCandidateApiResource {
|
|||||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||||
return req.getRemoteAddr();
|
return req.getRemoteAddr();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String esc(String s) {
|
||||||
|
if (s == null) return "null";
|
||||||
|
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-11
@@ -18,6 +18,7 @@ import jakarta.ws.rs.core.Context;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.fineract.portfolio.election.data.ElectionConfigData;
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionConfigRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionConfigRequest;
|
||||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
||||||
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
||||||
@@ -74,8 +75,10 @@ public class ElectionConfigApiResource {
|
|||||||
ElectionConfigRequest request,
|
ElectionConfigRequest request,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.updateElection(electionId, request);
|
writeService.updateElection(electionId, request);
|
||||||
|
String updateDetails = null;
|
||||||
|
try { updateDetails = objectMapper.writeValueAsString(request); } catch (Exception ignored) {}
|
||||||
electionAuditService.log(ElectionAuditAction.UPDATE_ELECTION, "ELECTION", electionId, electionId,
|
electionAuditService.log(ElectionAuditAction.UPDATE_ELECTION, "ELECTION", electionId, electionId,
|
||||||
"Election updated", null, clientIp(httpRequest));
|
"Election updated: " + request.getTitle(), updateDetails, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", electionId)).build();
|
return Response.ok(Map.of("resourceId", electionId)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +87,17 @@ public class ElectionConfigApiResource {
|
|||||||
@Operation(summary = "Activate election to open nominations")
|
@Operation(summary = "Activate election to open nominations")
|
||||||
public Response activateElection(@PathParam("electionId") Long electionId,
|
public Response activateElection(@PathParam("electionId") Long electionId,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.activateElection(electionId);
|
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||||
|
try {
|
||||||
|
writeService.activateElection(electionId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.ACTIVATE_ELECTION_FAILED,
|
||||||
|
"ELECTION", electionId, electionId, "Activate failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"ACTIVE\"}";
|
||||||
electionAuditService.log(ElectionAuditAction.ACTIVATE_ELECTION, "ELECTION", electionId, electionId,
|
electionAuditService.log(ElectionAuditAction.ACTIVATE_ELECTION, "ELECTION", electionId, electionId,
|
||||||
"Election activated", null, clientIp(httpRequest));
|
"Election activated — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", electionId, "status", "ACTIVE")).build();
|
return Response.ok(Map.of("resourceId", electionId, "status", "ACTIVE")).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,9 +106,17 @@ public class ElectionConfigApiResource {
|
|||||||
@Operation(summary = "Close nomination window")
|
@Operation(summary = "Close nomination window")
|
||||||
public Response closeNominations(@PathParam("electionId") Long electionId,
|
public Response closeNominations(@PathParam("electionId") Long electionId,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.closeNominations(electionId);
|
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||||
|
try {
|
||||||
|
writeService.closeNominations(electionId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.CLOSE_NOMINATIONS_FAILED,
|
||||||
|
"ELECTION", electionId, electionId, "Close nominations failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"NOMINATION_CLOSED\"}";
|
||||||
electionAuditService.log(ElectionAuditAction.CLOSE_NOMINATIONS, "ELECTION", electionId, electionId,
|
electionAuditService.log(ElectionAuditAction.CLOSE_NOMINATIONS, "ELECTION", electionId, electionId,
|
||||||
"Nominations closed", null, clientIp(httpRequest));
|
"Nominations closed — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", electionId, "status", "NOMINATION_CLOSED")).build();
|
return Response.ok(Map.of("resourceId", electionId, "status", "NOMINATION_CLOSED")).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +125,17 @@ public class ElectionConfigApiResource {
|
|||||||
@Operation(summary = "Open voting")
|
@Operation(summary = "Open voting")
|
||||||
public Response openVoting(@PathParam("electionId") Long electionId,
|
public Response openVoting(@PathParam("electionId") Long electionId,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.openVoting(electionId);
|
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||||
|
try {
|
||||||
|
writeService.openVoting(electionId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.OPEN_VOTING_FAILED,
|
||||||
|
"ELECTION", electionId, electionId, "Open voting failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"VOTING_OPEN\"}";
|
||||||
electionAuditService.log(ElectionAuditAction.OPEN_VOTING, "ELECTION", electionId, electionId,
|
electionAuditService.log(ElectionAuditAction.OPEN_VOTING, "ELECTION", electionId, electionId,
|
||||||
"Voting opened", null, clientIp(httpRequest));
|
"Voting opened — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_OPEN")).build();
|
return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_OPEN")).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +144,17 @@ public class ElectionConfigApiResource {
|
|||||||
@Operation(summary = "Close voting")
|
@Operation(summary = "Close voting")
|
||||||
public Response closeVoting(@PathParam("electionId") Long electionId,
|
public Response closeVoting(@PathParam("electionId") Long electionId,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.closeVoting(electionId);
|
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||||
|
try {
|
||||||
|
writeService.closeVoting(electionId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.CLOSE_VOTING_FAILED,
|
||||||
|
"ELECTION", electionId, electionId, "Close voting failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"VOTING_CLOSED\"}";
|
||||||
electionAuditService.log(ElectionAuditAction.CLOSE_VOTING, "ELECTION", electionId, electionId,
|
electionAuditService.log(ElectionAuditAction.CLOSE_VOTING, "ELECTION", electionId, electionId,
|
||||||
"Voting closed", null, clientIp(httpRequest));
|
"Voting closed — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_CLOSED")).build();
|
return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_CLOSED")).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,9 +164,19 @@ public class ElectionConfigApiResource {
|
|||||||
public Response cancelElection(@PathParam("electionId") Long electionId,
|
public Response cancelElection(@PathParam("electionId") Long electionId,
|
||||||
@QueryParam("reason") String reason,
|
@QueryParam("reason") String reason,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.cancelElection(electionId, reason);
|
ElectionConfigData elec = readService.retrieveElection(electionId);
|
||||||
|
try {
|
||||||
|
writeService.cancelElection(electionId, reason);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.CANCEL_ELECTION_FAILED,
|
||||||
|
"ELECTION", electionId, electionId, "Cancel failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null)
|
||||||
|
+ ",\"newStatus\":\"CANCELLED\""
|
||||||
|
+ (reason != null ? ",\"reason\":" + esc(reason) : "") + "}";
|
||||||
electionAuditService.log(ElectionAuditAction.CANCEL_ELECTION, "ELECTION", electionId, electionId,
|
electionAuditService.log(ElectionAuditAction.CANCEL_ELECTION, "ELECTION", electionId, electionId,
|
||||||
"Election cancelled: " + reason, null, clientIp(httpRequest));
|
"Election cancelled: " + reason, det, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", electionId, "status", "CANCELLED")).build();
|
return Response.ok(Map.of("resourceId", electionId, "status", "CANCELLED")).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,4 +204,9 @@ public class ElectionConfigApiResource {
|
|||||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||||
return req.getRemoteAddr();
|
return req.getRemoteAddr();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String esc(String s) {
|
||||||
|
if (s == null) return "null";
|
||||||
|
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-15
@@ -15,6 +15,8 @@ import jakarta.ws.rs.core.MediaType;
|
|||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest;
|
||||||
|
import org.apache.fineract.portfolio.election.data.ElectionApprovalStepData;
|
||||||
|
import org.apache.fineract.portfolio.election.data.ElectionNominationData;
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
|
||||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
||||||
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
||||||
@@ -43,11 +45,24 @@ public class ElectionNominationApiResource {
|
|||||||
ElectionNominationRequest request,
|
ElectionNominationRequest request,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
request.setElectionId(electionId);
|
request.setElectionId(electionId);
|
||||||
Long id = writeService.submitNomination(request);
|
Long id;
|
||||||
|
try {
|
||||||
|
id = writeService.submitNomination(request);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.SUBMIT_NOMINATION_FAILED,
|
||||||
|
"NOMINATION", null, electionId,
|
||||||
|
"Nomination submit failed for member " + request.getMemberId() + ": " + e.getMessage(),
|
||||||
|
null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
String subDetails = "{\"applicantName\":" + esc(request.getApplicantName())
|
||||||
|
+ ",\"memberId\":" + request.getMemberId()
|
||||||
|
+ ",\"postId\":" + request.getPostId()
|
||||||
|
+ ",\"gender\":" + esc(request.getGender())
|
||||||
|
+ ",\"caste\":" + esc(request.getCaste())
|
||||||
|
+ "}";
|
||||||
electionAuditService.log(ElectionAuditAction.SUBMIT_NOMINATION, "NOMINATION", id, electionId,
|
electionAuditService.log(ElectionAuditAction.SUBMIT_NOMINATION, "NOMINATION", id, electionId,
|
||||||
"Nomination submitted by member " + request.getMemberId(),
|
"Nomination submitted — " + request.getApplicantName(), subDetails, clientIp(httpRequest));
|
||||||
null,
|
|
||||||
clientIp(httpRequest));
|
|
||||||
return Response.ok(Map.of("resourceId", id)).build();
|
return Response.ok(Map.of("resourceId", id)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +90,13 @@ public class ElectionNominationApiResource {
|
|||||||
public Response withdrawNomination(@PathParam("electionId") Long electionId,
|
public Response withdrawNomination(@PathParam("electionId") Long electionId,
|
||||||
@PathParam("nominationId") Long nominationId,
|
@PathParam("nominationId") Long nominationId,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.withdrawNomination(nominationId);
|
try {
|
||||||
|
writeService.withdrawNomination(nominationId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.WITHDRAW_NOMINATION_FAILED,
|
||||||
|
"NOMINATION", nominationId, electionId, "Withdraw failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
electionAuditService.log(ElectionAuditAction.WITHDRAW_NOMINATION, "NOMINATION", nominationId, electionId,
|
electionAuditService.log(ElectionAuditAction.WITHDRAW_NOMINATION, "NOMINATION", nominationId, electionId,
|
||||||
"Nomination withdrawn", null, clientIp(httpRequest));
|
"Nomination withdrawn", null, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("resourceId", nominationId, "status", "WITHDRAWN")).build();
|
return Response.ok(Map.of("resourceId", nominationId, "status", "WITHDRAWN")).build();
|
||||||
@@ -99,10 +120,40 @@ public class ElectionNominationApiResource {
|
|||||||
public Response runEligibilityCheck(@PathParam("electionId") Long electionId,
|
public Response runEligibilityCheck(@PathParam("electionId") Long electionId,
|
||||||
@PathParam("nominationId") Long nominationId,
|
@PathParam("nominationId") Long nominationId,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.runEligibilityCheck(nominationId);
|
try {
|
||||||
|
writeService.runEligibilityCheck(nominationId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.ELIGIBILITY_CHECK_FAILED,
|
||||||
|
"NOMINATION", nominationId, electionId, "Eligibility check failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
ElectionNominationData eligData = readService.retrieveNomination(nominationId);
|
||||||
|
String eligResult = eligData.getEligibilityCheck() != null
|
||||||
|
? eligData.getEligibilityCheck().getOverallResult() : null;
|
||||||
|
String eligDesc = "FAIL".equals(eligResult)
|
||||||
|
? "Eligibility check failed — " + (eligData.getRejectionReason() != null ? eligData.getRejectionReason() : "not eligible")
|
||||||
|
: "Eligibility check passed";
|
||||||
|
String eligDetails = null;
|
||||||
|
if (eligData.getEligibilityCheck() != null) {
|
||||||
|
var ec = eligData.getEligibilityCheck();
|
||||||
|
StringBuilder det = new StringBuilder("{");
|
||||||
|
det.append("\"overallResult\":").append(esc(ec.getOverallResult()));
|
||||||
|
det.append(",\"age\":").append(esc(ec.getAgeEligible() != null ? (ec.getAgeEligible() ? "PASS" : "FAIL") : null));
|
||||||
|
det.append(",\"gender\":").append(esc(ec.getGenderEligible() != null ? (ec.getGenderEligible() ? "PASS" : "FAIL") : null));
|
||||||
|
det.append(",\"caste\":").append(esc(ec.getCasteEligible() != null ? (ec.getCasteEligible() ? "PASS" : "FAIL") : null));
|
||||||
|
det.append(",\"shareHolding\":").append(esc(ec.getShareHoldingEligible() != null ? (ec.getShareHoldingEligible() ? "PASS" : "FAIL") : null));
|
||||||
|
det.append(",\"area\":").append(esc(ec.getAreaEligible() != null ? (ec.getAreaEligible() ? "PASS" : "FAIL") : null));
|
||||||
|
if (ec.getAgeCheckRemarks() != null) det.append(",\"ageRemarks\":").append(esc(ec.getAgeCheckRemarks()));
|
||||||
|
if (ec.getGenderCheckRemarks() != null) det.append(",\"genderRemarks\":").append(esc(ec.getGenderCheckRemarks()));
|
||||||
|
if (ec.getCasteCheckRemarks() != null) det.append(",\"casteRemarks\":").append(esc(ec.getCasteCheckRemarks()));
|
||||||
|
if (ec.getShareCheckRemarks() != null) det.append(",\"shareRemarks\":").append(esc(ec.getShareCheckRemarks()));
|
||||||
|
if (ec.getAreaCheckRemarks() != null) det.append(",\"areaRemarks\":").append(esc(ec.getAreaCheckRemarks()));
|
||||||
|
det.append("}");
|
||||||
|
eligDetails = det.toString();
|
||||||
|
}
|
||||||
electionAuditService.log(ElectionAuditAction.ELIGIBILITY_CHECK, "NOMINATION", nominationId, electionId,
|
electionAuditService.log(ElectionAuditAction.ELIGIBILITY_CHECK, "NOMINATION", nominationId, electionId,
|
||||||
"Eligibility check run", null, clientIp(httpRequest));
|
eligDesc, eligDetails, clientIp(httpRequest));
|
||||||
return Response.ok(readService.retrieveNomination(nominationId)).build();
|
return Response.ok(eligData).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -111,18 +162,60 @@ public class ElectionNominationApiResource {
|
|||||||
public Response runCeoPreCheck(@PathParam("electionId") Long electionId,
|
public Response runCeoPreCheck(@PathParam("electionId") Long electionId,
|
||||||
@PathParam("nominationId") Long nominationId,
|
@PathParam("nominationId") Long nominationId,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.runCeoPreCheck(nominationId);
|
try {
|
||||||
electionAuditService.log(ElectionAuditAction.CEO_PRECHECK, "NOMINATION", nominationId, electionId,
|
writeService.runCeoPreCheck(nominationId);
|
||||||
"CEO pre-check run", null, clientIp(httpRequest));
|
} catch (RuntimeException e) {
|
||||||
return Response.ok(readService.retrieveNomination(nominationId)).build();
|
electionAuditService.logInNewTransaction(ElectionAuditAction.CEO_PRECHECK_FAILED,
|
||||||
|
"NOMINATION", nominationId, electionId, "CEO pre-check failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
ElectionNominationData nominationData = readService.retrieveNomination(nominationId);
|
||||||
|
boolean ceoFailed = nominationData.getCeoPreCheck() != null
|
||||||
|
&& "FAIL".equals(nominationData.getCeoPreCheck().getOverallResult());
|
||||||
|
String ceoDetails = null;
|
||||||
|
if (nominationData.getCeoPreCheck() != null) {
|
||||||
|
var cp = nominationData.getCeoPreCheck();
|
||||||
|
StringBuilder det = new StringBuilder("{");
|
||||||
|
det.append("\"directorLoan\":").append(esc(cp.getDirectorLoanCheckResult()));
|
||||||
|
if (cp.getDirectorLoanOutstanding() != null) det.append(",\"directorLoanOutstanding\":").append(cp.getDirectorLoanOutstanding());
|
||||||
|
if (cp.getDirectorLoanRemarks() != null) det.append(",\"directorLoanRemarks\":").append(esc(cp.getDirectorLoanRemarks()));
|
||||||
|
det.append(",\"relationAccount\":").append(esc(cp.getRelationAcCheckResult()));
|
||||||
|
if (cp.getRelationAcRemarks() != null) det.append(",\"relationRemarks\":").append(esc(cp.getRelationAcRemarks()));
|
||||||
|
det.append(",\"kyc\":").append(esc(cp.getKycCheckResult()));
|
||||||
|
if (cp.getKycCheckRemarks() != null) det.append(",\"kycRemarks\":").append(esc(cp.getKycCheckRemarks()));
|
||||||
|
det.append(",\"familyKyc\":").append(esc(cp.getFamilyKycCheckResult()));
|
||||||
|
if (cp.getFamilyKycCheckRemarks() != null) det.append(",\"familyKycRemarks\":").append(esc(cp.getFamilyKycCheckRemarks()));
|
||||||
|
det.append(",\"guarantor\":").append(esc(cp.getGuarantorCheckResult()));
|
||||||
|
if (cp.getGuarantorCheckRemarks() != null) det.append(",\"guarantorRemarks\":").append(esc(cp.getGuarantorCheckRemarks()));
|
||||||
|
det.append(",\"aml\":").append(esc(cp.getAmlCheckResult()));
|
||||||
|
if (cp.getAmlCheckRemarks() != null) det.append(",\"amlRemarks\":").append(esc(cp.getAmlCheckRemarks()));
|
||||||
|
det.append(",\"familyLoan\":").append(esc(cp.getFamilyLoanCheckResult()));
|
||||||
|
if (cp.getFamilyLoanCheckRemarks() != null) det.append(",\"familyLoanRemarks\":").append(esc(cp.getFamilyLoanCheckRemarks()));
|
||||||
|
det.append("}");
|
||||||
|
ceoDetails = det.toString();
|
||||||
|
}
|
||||||
|
if (ceoFailed) {
|
||||||
|
String reason = nominationData.getRejectionReason() != null
|
||||||
|
? nominationData.getRejectionReason() : "checks did not pass";
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.CEO_PRECHECK_FAILED,
|
||||||
|
"NOMINATION", nominationId, electionId,
|
||||||
|
"CEO pre-check failed — " + reason, ceoDetails, clientIp(httpRequest));
|
||||||
|
} else {
|
||||||
|
electionAuditService.log(ElectionAuditAction.CEO_PRECHECK, "NOMINATION", nominationId, electionId,
|
||||||
|
"CEO pre-check passed", ceoDetails, clientIp(httpRequest));
|
||||||
|
}
|
||||||
|
return Response.ok(nominationData).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/{electionId}/nomination/{nominationId}/no-due-certificate")
|
@Path("/{electionId}/nomination/{nominationId}/no-due-certificate")
|
||||||
@Operation(summary = "Generate No Due Certificate for nominee")
|
@Operation(summary = "Generate No Due Certificate for nominee")
|
||||||
public Response generateNoDueCertificate(@PathParam("electionId") Long electionId,
|
public Response generateNoDueCertificate(@PathParam("electionId") Long electionId,
|
||||||
@PathParam("nominationId") Long nominationId) {
|
@PathParam("nominationId") Long nominationId,
|
||||||
|
@Context HttpServletRequest httpRequest) {
|
||||||
String certNumber = writeService.generateNoDueCertificate(nominationId);
|
String certNumber = writeService.generateNoDueCertificate(nominationId);
|
||||||
|
electionAuditService.log(ElectionAuditAction.GENERATE_NO_DUE_CERTIFICATE, "NOMINATION", nominationId, electionId,
|
||||||
|
"No due certificate generated: " + certNumber, null, clientIp(httpRequest));
|
||||||
return Response.ok(Map.of("certificateNumber", certNumber)).build();
|
return Response.ok(Map.of("certificateNumber", certNumber)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +226,44 @@ public class ElectionNominationApiResource {
|
|||||||
@PathParam("nominationId") Long nominationId,
|
@PathParam("nominationId") Long nominationId,
|
||||||
ElectionApprovalActionRequest request,
|
ElectionApprovalActionRequest request,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
writeService.processApprovalStep(nominationId, request);
|
// Capture the current PENDING step BEFORE processing — status changes to APPROVED/REJECTED after
|
||||||
|
ElectionNominationData beforeData = readService.retrieveNomination(nominationId);
|
||||||
|
ElectionApprovalStepData pendingStep = beforeData.getApprovalSteps() != null
|
||||||
|
? beforeData.getApprovalSteps().stream()
|
||||||
|
.filter(s -> "PENDING".equalsIgnoreCase(s.getStatus()))
|
||||||
|
.findFirst().orElse(null)
|
||||||
|
: null;
|
||||||
|
try {
|
||||||
|
writeService.processApprovalStep(nominationId, request);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.APPROVAL_ACTION_FAILED,
|
||||||
|
"NOMINATION", nominationId, electionId,
|
||||||
|
"Approval action failed (" + request.getAction() + "): " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
StringBuilder approvalDesc = new StringBuilder("Approval action: ").append(request.getAction());
|
||||||
|
StringBuilder approvalDet = new StringBuilder("{\"action\":").append(esc(request.getAction()));
|
||||||
|
if (pendingStep != null) {
|
||||||
|
if (pendingStep.getStepName() != null) {
|
||||||
|
approvalDesc.append(" — Step: ").append(pendingStep.getStepName());
|
||||||
|
approvalDet.append(",\"stepName\":").append(esc(pendingStep.getStepName()));
|
||||||
|
}
|
||||||
|
if (pendingStep.getStepNumber() != null) {
|
||||||
|
approvalDesc.append(" (#").append(pendingStep.getStepNumber()).append(")");
|
||||||
|
approvalDet.append(",\"stepNumber\":").append(pendingStep.getStepNumber());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (request.getRemarks() != null && !request.getRemarks().isBlank()) {
|
||||||
|
approvalDesc.append(" — Remarks: ").append(request.getRemarks());
|
||||||
|
approvalDet.append(",\"remarks\":").append(esc(request.getRemarks()));
|
||||||
|
}
|
||||||
|
if ("REJECT".equalsIgnoreCase(request.getAction()) && request.getRejectionReason() != null && !request.getRejectionReason().isBlank()) {
|
||||||
|
approvalDesc.append(" — Reason: ").append(request.getRejectionReason());
|
||||||
|
approvalDet.append(",\"rejectionReason\":").append(esc(request.getRejectionReason()));
|
||||||
|
}
|
||||||
|
approvalDet.append("}");
|
||||||
electionAuditService.log(ElectionAuditAction.APPROVAL_ACTION, "NOMINATION", nominationId, electionId,
|
electionAuditService.log(ElectionAuditAction.APPROVAL_ACTION, "NOMINATION", nominationId, electionId,
|
||||||
"Approval action: " + request.getAction(), null, clientIp(httpRequest));
|
approvalDesc.toString(), approvalDet.toString(), clientIp(httpRequest));
|
||||||
return Response.ok(readService.retrieveNomination(nominationId)).build();
|
return Response.ok(readService.retrieveNomination(nominationId)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,4 +284,9 @@ public class ElectionNominationApiResource {
|
|||||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||||
return req.getRemoteAddr();
|
return req.getRemoteAddr();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String esc(String s) {
|
||||||
|
if (s == null) return "null";
|
||||||
|
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-46
@@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
package org.apache.fineract.portfolio.election.api;
|
package org.apache.fineract.portfolio.election.api;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -14,15 +21,16 @@ import jakarta.ws.rs.core.MediaType;
|
|||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionVoteRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionResultData;
|
||||||
|
import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry;
|
||||||
|
import org.apache.fineract.portfolio.election.data.ManualVoteCountsRequest;
|
||||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
||||||
import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository;
|
import org.apache.fineract.portfolio.election.exception.VoteCountExceedsShareAccountsException;
|
||||||
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
||||||
import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService;
|
import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService;
|
||||||
import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService;
|
import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -37,36 +45,73 @@ public class ElectionVotingApiResource {
|
|||||||
private final ElectionReadPlatformService readService;
|
private final ElectionReadPlatformService readService;
|
||||||
private final ElectionWritePlatformService writeService;
|
private final ElectionWritePlatformService writeService;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final ElectionVoteRepository voteRepository;
|
|
||||||
private final ElectionAuditService electionAuditService;
|
private final ElectionAuditService electionAuditService;
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/{electionId}/vote")
|
@Path("/{electionId}/manual-vote-counts")
|
||||||
@Operation(summary = "Cast a vote for a candidate")
|
@Operation(summary = "Save manual vote counts for all candidates after physical voting")
|
||||||
public Response castVote(@PathParam("electionId") Long electionId,
|
public Response saveManualVoteCounts(@PathParam("electionId") Long electionId,
|
||||||
ElectionVoteRequest request,
|
ManualVoteCountsRequest request,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
request.setElectionId(electionId);
|
List<ManualVoteCountEntry> counts = request.getCounts() != null ? request.getCounts() : List.of();
|
||||||
writeService.castVote(request);
|
String reason = request.getReason();
|
||||||
electionAuditService.log(ElectionAuditAction.CAST_VOTE, "VOTE", null, electionId,
|
String voteDetails;
|
||||||
"Vote cast for post " + request.getPostId() + " candidate " + request.getCandidateId(),
|
try {
|
||||||
"{\"postId\":" + request.getPostId()
|
voteDetails = writeService.saveManualVoteCounts(electionId, counts, reason);
|
||||||
+ ",\"candidateId\":" + request.getCandidateId()
|
} catch (VoteCountExceedsShareAccountsException e) {
|
||||||
+ ",\"voterMemberId\":" + request.getVoterMemberId() + "}",
|
electionAuditService.logInNewTransaction(
|
||||||
clientIp(httpRequest));
|
ElectionAuditAction.MANUAL_VOTE_ENTRY_FAILED, "VOTE", electionId, electionId,
|
||||||
return Response.ok(Map.of("message", "Vote cast successfully")).build();
|
"Vote count validation failed: " + e.getDefaultUserMessage(),
|
||||||
|
null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
boolean isEdit = voteDetails.contains("\"previousVotes\"");
|
||||||
|
String auditMsg = (isEdit ? "Vote counts updated" : "Vote counts entered")
|
||||||
|
+ " for " + counts.size() + " candidates"
|
||||||
|
+ (reason != null && !reason.isBlank() ? " — Reason: " + reason : "");
|
||||||
|
electionAuditService.log(ElectionAuditAction.MANUAL_VOTE_ENTRY, "VOTE", electionId, electionId,
|
||||||
|
auditMsg, voteDetails, clientIp(httpRequest));
|
||||||
|
return Response.ok(Map.of("message", "Manual vote counts saved successfully")).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/{electionId}/declare-results")
|
@Path("/{electionId}/declare-results")
|
||||||
@Operation(summary = "Declare election results")
|
@Operation(summary = "Declare election results using saved manual vote counts")
|
||||||
public Response declareResults(@PathParam("electionId") Long electionId,
|
public Response declareResults(@PathParam("electionId") Long electionId,
|
||||||
@Context HttpServletRequest httpRequest) {
|
@Context HttpServletRequest httpRequest) {
|
||||||
Long declaredBy = context.authenticatedUser().getId();
|
Long declaredBy = context.authenticatedUser().getId();
|
||||||
writeService.declareResults(electionId, declaredBy);
|
try {
|
||||||
|
writeService.declareResults(electionId, declaredBy);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
electionAuditService.logInNewTransaction(ElectionAuditAction.DECLARE_RESULTS_FAILED,
|
||||||
|
"RESULT", electionId, electionId, "Declare results failed: " + e.getMessage(), null, clientIp(httpRequest));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
java.util.List<ElectionResultData> results = readService.retrieveResultsByElection(electionId);
|
||||||
|
// Build winner summary for audit details
|
||||||
|
StringBuilder winDet = new StringBuilder("[");
|
||||||
|
boolean wFirst = true;
|
||||||
|
if (results != null) {
|
||||||
|
for (ElectionResultData r : results) {
|
||||||
|
if (Integer.valueOf(1).equals(r.getRankPosition()) || "WON".equalsIgnoreCase(r.getResult())) {
|
||||||
|
if (!wFirst) winDet.append(",");
|
||||||
|
winDet.append("{\"post\":").append(esc(r.getPostName()))
|
||||||
|
.append(",\"winner\":").append(esc(r.getCandidateName()))
|
||||||
|
.append(",\"votes\":").append(r.getVoteCount() != null ? r.getVoteCount() : 0)
|
||||||
|
.append("}");
|
||||||
|
wFirst = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
winDet.append("]");
|
||||||
|
String declareDetails = wFirst ? null : winDet.toString();
|
||||||
|
String declareDesc = "Results declared" + (declareDetails != null ? " — " + (results.stream()
|
||||||
|
.filter(r -> Integer.valueOf(1).equals(r.getRankPosition()) || "WON".equalsIgnoreCase(r.getResult()))
|
||||||
|
.map(r -> r.getCandidateName() + " won " + r.getPostName())
|
||||||
|
.reduce((a, b) -> a + "; " + b).orElse("")) : "");
|
||||||
electionAuditService.log(ElectionAuditAction.DECLARE_RESULTS, "RESULT", electionId, electionId,
|
electionAuditService.log(ElectionAuditAction.DECLARE_RESULTS, "RESULT", electionId, electionId,
|
||||||
"Results declared", null, clientIp(httpRequest));
|
declareDesc, declareDetails, clientIp(httpRequest));
|
||||||
return Response.ok(readService.retrieveResultsByElection(electionId)).build();
|
return Response.ok(results).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -76,30 +121,6 @@ public class ElectionVotingApiResource {
|
|||||||
return Response.ok(readService.retrieveResultsByElection(electionId)).build();
|
return Response.ok(readService.retrieveResultsByElection(electionId)).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("/{electionId}/vote-summary")
|
|
||||||
@Operation(summary = "Get live vote counts per candidate per post")
|
|
||||||
public Response getVoteSummary(@PathParam("electionId") Long electionId) {
|
|
||||||
long totalVotes = voteRepository.countByElectionConfigId(electionId);
|
|
||||||
long totalVoters = voteRepository.countTotalVotersByElection(electionId);
|
|
||||||
List<Object[]> rows = voteRepository.voteSummaryByCandidate(electionId);
|
|
||||||
|
|
||||||
List<Map<String, Object>> breakdown = rows.stream().map(r -> {
|
|
||||||
Map<String, Object> entry = new HashMap<>();
|
|
||||||
entry.put("postId", r[0]);
|
|
||||||
entry.put("candidateId", r[1]);
|
|
||||||
entry.put("voteCount", r[2]);
|
|
||||||
return entry;
|
|
||||||
}).collect(java.util.stream.Collectors.toList());
|
|
||||||
|
|
||||||
Map<String, Object> summary = new HashMap<>();
|
|
||||||
summary.put("electionId", electionId);
|
|
||||||
summary.put("totalVotes", totalVotes);
|
|
||||||
summary.put("totalVoters", totalVoters);
|
|
||||||
summary.put("breakdown", breakdown);
|
|
||||||
return Response.ok(summary).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -110,4 +131,9 @@ public class ElectionVotingApiResource {
|
|||||||
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
|
||||||
return req.getRemoteAddr();
|
return req.getRemoteAddr();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String esc(String s) {
|
||||||
|
if (s == null) return "null";
|
||||||
|
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -31,4 +31,5 @@ public class ElectionCandidateData {
|
|||||||
private String location;
|
private String location;
|
||||||
private Integer shareCount;
|
private Integer shareCount;
|
||||||
private Integer voteCount;
|
private Integer voteCount;
|
||||||
|
private Integer manualVoteCount;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.election.data;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ManualVoteCountEntry {
|
||||||
|
private Long candidateId;
|
||||||
|
private Integer voteCount;
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package org.apache.fineract.portfolio.election.data;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ManualVoteCountsRequest {
|
||||||
|
|
||||||
|
private List<ManualVoteCountEntry> counts;
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
+11
-2
@@ -5,13 +5,22 @@ public enum ElectionAuditAction {
|
|||||||
CREATE_ELECTION, UPDATE_ELECTION, ACTIVATE_ELECTION,
|
CREATE_ELECTION, UPDATE_ELECTION, ACTIVATE_ELECTION,
|
||||||
CLOSE_NOMINATIONS, OPEN_VOTING, CLOSE_VOTING, CANCEL_ELECTION,
|
CLOSE_NOMINATIONS, OPEN_VOTING, CLOSE_VOTING, CANCEL_ELECTION,
|
||||||
CREATE_POST, UPDATE_POST,
|
CREATE_POST, UPDATE_POST,
|
||||||
|
// Election Config — Failed
|
||||||
|
ACTIVATE_ELECTION_FAILED, CLOSE_NOMINATIONS_FAILED,
|
||||||
|
OPEN_VOTING_FAILED, CLOSE_VOTING_FAILED, CANCEL_ELECTION_FAILED,
|
||||||
// Nomination
|
// Nomination
|
||||||
SUBMIT_NOMINATION, WITHDRAW_NOMINATION, RESUBMIT_NOMINATION,
|
SUBMIT_NOMINATION, WITHDRAW_NOMINATION, RESUBMIT_NOMINATION,
|
||||||
ELIGIBILITY_CHECK, CEO_PRECHECK, APPROVAL_ACTION,
|
ELIGIBILITY_CHECK, CEO_PRECHECK, APPROVAL_ACTION,
|
||||||
|
GENERATE_NO_DUE_CERTIFICATE,
|
||||||
|
// Nomination — Failed
|
||||||
|
SUBMIT_NOMINATION_FAILED, WITHDRAW_NOMINATION_FAILED,
|
||||||
|
ELIGIBILITY_CHECK_FAILED, CEO_PRECHECK_FAILED, APPROVAL_ACTION_FAILED,
|
||||||
// Candidate
|
// Candidate
|
||||||
APPROVE_CANDIDATE, REMOVE_CANDIDATE, UPLOAD_SYMBOL, UPDATE_CANDIDATE,
|
APPROVE_CANDIDATE, REMOVE_CANDIDATE, UPLOAD_SYMBOL, UPDATE_CANDIDATE,
|
||||||
|
// Candidate — Failed
|
||||||
|
APPROVE_CANDIDATE_FAILED, REMOVE_CANDIDATE_FAILED,
|
||||||
// Voting
|
// Voting
|
||||||
CAST_VOTE,
|
CAST_VOTE, MANUAL_VOTE_ENTRY, MANUAL_VOTE_ENTRY_FAILED,
|
||||||
// Results
|
// Results
|
||||||
DECLARE_RESULTS
|
DECLARE_RESULTS, DECLARE_RESULTS_FAILED
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ public class ElectionAuditLog {
|
|||||||
@Column(name = "details", columnDefinition = "TEXT")
|
@Column(name = "details", columnDefinition = "TEXT")
|
||||||
private String details;
|
private String details;
|
||||||
|
|
||||||
@Column(name = "description", length = 500)
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
|
|||||||
+3
@@ -51,6 +51,9 @@ public class ElectionCandidate extends AbstractPersistableCustom<Long> {
|
|||||||
@Column(name = "brief_profile", columnDefinition = "TEXT")
|
@Column(name = "brief_profile", columnDefinition = "TEXT")
|
||||||
private String briefProfile;
|
private String briefProfile;
|
||||||
|
|
||||||
|
@Column(name = "manual_vote_count")
|
||||||
|
private Integer manualVoteCount;
|
||||||
|
|
||||||
public static ElectionCandidate create(ElectionConfig electionConfig,
|
public static ElectionCandidate create(ElectionConfig electionConfig,
|
||||||
ElectionNomination nomination,
|
ElectionNomination nomination,
|
||||||
ElectionPost electionPost,
|
ElectionPost electionPost,
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.election.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
public class VoteCountExceedsShareAccountsException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public VoteCountExceedsShareAccountsException(String postName, int totalVotes, int activeShareAccounts) {
|
||||||
|
super("error.msg.election.vote.count.exceeds.share.accounts",
|
||||||
|
"Total votes for post '" + postName + "' is " + totalVotes
|
||||||
|
+ ", which exceeds the number of active share accounts (" + activeShareAccounts
|
||||||
|
+ "). Votes cannot exceed the total number of eligible voters.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package org.apache.fineract.portfolio.election.exception;
|
||||||
|
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
|
||||||
|
|
||||||
|
public class VoteCountsNotSavedException extends AbstractPlatformDomainRuleException {
|
||||||
|
|
||||||
|
public VoteCountsNotSavedException(Long electionId, String postName) {
|
||||||
|
super("error.msg.election.vote.counts.not.saved",
|
||||||
|
"Manual vote counts have not been saved for post '" + postName + "' in election " + electionId
|
||||||
|
+ ". Please enter and save vote counts on the Results page before closing voting.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -15,6 +15,14 @@ public interface ElectionAuditService {
|
|||||||
void log(ElectionAuditAction action, String entityType, Long entityId,
|
void log(ElectionAuditAction action, String entityType, Long entityId,
|
||||||
Long electionId, String description, String details, String ipAddress);
|
Long electionId, String description, String details, String ipAddress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #log} but always runs in a brand-new transaction so the
|
||||||
|
* entry is committed even if the caller's transaction rolls back (e.g. on
|
||||||
|
* validation failure).
|
||||||
|
*/
|
||||||
|
void logInNewTransaction(ElectionAuditAction action, String entityType, Long entityId,
|
||||||
|
Long electionId, String description, String details, String ipAddress);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side filtered + paginated audit log for an election.
|
* Server-side filtered + paginated audit log for an election.
|
||||||
*
|
*
|
||||||
|
|||||||
+8
@@ -8,6 +8,7 @@ import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
|||||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditLog;
|
import org.apache.fineract.portfolio.election.domain.ElectionAuditLog;
|
||||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditLogRepository;
|
import org.apache.fineract.portfolio.election.domain.ElectionAuditLogRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -73,6 +74,13 @@ public class ElectionAuditServiceImpl implements ElectionAuditService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void logInNewTransaction(ElectionAuditAction action, String entityType, Long entityId,
|
||||||
|
Long electionId, String description, String details, String ipAddress) {
|
||||||
|
log(action, entityType, entityId, electionId, description, details, ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Map<String, Object> getAuditLogPaged(Long electionId, String entityType,
|
public Map<String, Object> getAuditLogPaged(Long electionId, String entityType,
|
||||||
|
|||||||
+1
-1
@@ -279,7 +279,7 @@ public class ElectionCeoPreCheckServiceImpl implements ElectionCeoPreCheckServic
|
|||||||
|
|
||||||
// Manual (unlinked) family members — loan status cannot be verified in CBS
|
// Manual (unlinked) family members — loan status cannot be verified in CBS
|
||||||
String manualQuery = """
|
String manualQuery = """
|
||||||
SELECT TRIM(CONCAT(COALESCE(fm.first_name, ''), ' ', COALESCE(fm.last_name, ''))) AS full_name
|
SELECT TRIM(CONCAT(COALESCE(fm.firstname, ''), ' ', COALESCE(fm.lastname, ''))) AS full_name
|
||||||
FROM m_family_members fm
|
FROM m_family_members fm
|
||||||
WHERE fm.client_id = ?
|
WHERE fm.client_id = ?
|
||||||
AND fm.linked_client_id IS NULL
|
AND fm.linked_client_id IS NULL
|
||||||
|
|||||||
+1
@@ -250,6 +250,7 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
|
|||||||
.approvedDate(c.getApprovedDate())
|
.approvedDate(c.getApprovedDate())
|
||||||
.hasSymbol(c.getSymbol() != null)
|
.hasSymbol(c.getSymbol() != null)
|
||||||
.briefProfile(c.getBriefProfile())
|
.briefProfile(c.getBriefProfile())
|
||||||
|
.manualVoteCount(c.getManualVoteCount())
|
||||||
.build())
|
.build())
|
||||||
.collect(Collectors.toList()))
|
.collect(Collectors.toList()))
|
||||||
.pageNumber(result.getNumber())
|
.pageNumber(result.getNumber())
|
||||||
|
|||||||
+12
@@ -1,9 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
package org.apache.fineract.portfolio.election.service;
|
package org.apache.fineract.portfolio.election.service;
|
||||||
|
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest;
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionConfigRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionConfigRequest;
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionVoteRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionVoteRequest;
|
||||||
|
import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface ElectionWritePlatformService {
|
public interface ElectionWritePlatformService {
|
||||||
|
|
||||||
@@ -45,5 +55,7 @@ public interface ElectionWritePlatformService {
|
|||||||
|
|
||||||
void castVote(ElectionVoteRequest request);
|
void castVote(ElectionVoteRequest request);
|
||||||
|
|
||||||
|
String saveManualVoteCounts(Long electionId, List<ManualVoteCountEntry> counts, String reason);
|
||||||
|
|
||||||
void declareResults(Long electionId, Long declaredBy);
|
void declareResults(Long electionId, Long declaredBy);
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-24
@@ -8,6 +8,7 @@ import org.apache.fineract.portfolio.election.data.ElectionConfigRequest;
|
|||||||
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionPostRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionPostRequest;
|
||||||
import org.apache.fineract.portfolio.election.data.ElectionVoteRequest;
|
import org.apache.fineract.portfolio.election.data.ElectionVoteRequest;
|
||||||
|
import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry;
|
||||||
import org.apache.fineract.portfolio.election.domain.ApprovalStepStatus;
|
import org.apache.fineract.portfolio.election.domain.ApprovalStepStatus;
|
||||||
import org.apache.fineract.portfolio.election.domain.ElectionApprovalStep;
|
import org.apache.fineract.portfolio.election.domain.ElectionApprovalStep;
|
||||||
import org.apache.fineract.portfolio.election.domain.ElectionApprovalStepRepository;
|
import org.apache.fineract.portfolio.election.domain.ElectionApprovalStepRepository;
|
||||||
@@ -34,11 +35,14 @@ import org.apache.fineract.portfolio.election.exception.ElectionNotActiveExcepti
|
|||||||
import org.apache.fineract.portfolio.election.exception.InvalidNominationStateException;
|
import org.apache.fineract.portfolio.election.exception.InvalidNominationStateException;
|
||||||
import org.apache.fineract.portfolio.election.exception.NominationAlreadyExistsException;
|
import org.apache.fineract.portfolio.election.exception.NominationAlreadyExistsException;
|
||||||
import org.apache.fineract.portfolio.election.exception.VoteAlreadyCastException;
|
import org.apache.fineract.portfolio.election.exception.VoteAlreadyCastException;
|
||||||
|
import org.apache.fineract.portfolio.election.exception.VoteCountExceedsShareAccountsException;
|
||||||
|
import org.apache.fineract.portfolio.election.exception.VoteCountsNotSavedException;
|
||||||
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
|
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -158,6 +162,19 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
|
|||||||
context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION");
|
context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION");
|
||||||
ElectionConfig election = electionConfigRepository.findById(electionId)
|
ElectionConfig election = electionConfigRepository.findById(electionId)
|
||||||
.orElseThrow(() -> new ElectionNotFoundException(electionId));
|
.orElseThrow(() -> new ElectionNotFoundException(electionId));
|
||||||
|
|
||||||
|
List<ElectionPost> posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId);
|
||||||
|
for (ElectionPost post : posts) {
|
||||||
|
List<ElectionCandidate> candidates = candidateRepository
|
||||||
|
.findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(electionId, post.getId());
|
||||||
|
if (candidates.size() > 1) {
|
||||||
|
boolean countsEntered = candidates.stream().anyMatch(c -> c.getManualVoteCount() != null);
|
||||||
|
if (!countsEntered) {
|
||||||
|
throw new VoteCountsNotSavedException(electionId, post.getPostName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
election.closeVoting();
|
election.closeVoting();
|
||||||
electionConfigRepository.save(election);
|
electionConfigRepository.save(election);
|
||||||
}
|
}
|
||||||
@@ -431,27 +448,70 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void castVote(ElectionVoteRequest request) {
|
public void castVote(ElectionVoteRequest request) {
|
||||||
ElectionConfig election = electionConfigRepository.findById(request.getElectionId())
|
// Electronic voting disabled — voting is conducted manually.
|
||||||
.orElseThrow(() -> new ElectionNotFoundException(request.getElectionId()));
|
throw new UnsupportedOperationException("Electronic voting is disabled. Voting is conducted manually.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public String saveManualVoteCounts(Long electionId, List<ManualVoteCountEntry> counts, String reason) {
|
||||||
|
ElectionConfig election = electionConfigRepository.findById(electionId)
|
||||||
|
.orElseThrow(() -> new ElectionNotFoundException(electionId));
|
||||||
|
|
||||||
if (!election.isVotingOpen()) {
|
if (!election.isVotingOpen()) {
|
||||||
throw new ElectionNotActiveException(election.getId(), election.getStatus().name());
|
throw new ElectionNotActiveException(election.getId(), election.getStatus().name());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (voteRepository.existsByElectionConfigIdAndElectionPostIdAndVoterMemberId(
|
// Active share accounts = maximum eligible voters
|
||||||
request.getElectionId(), request.getPostId(), request.getVoterMemberId())) {
|
Integer activeShareAccounts = jdbcTemplate.queryForObject(
|
||||||
throw new VoteAlreadyCastException(request.getVoterMemberId(), request.getPostId());
|
"SELECT COUNT(*) FROM m_share_account WHERE status_enum = 300", Integer.class);
|
||||||
|
int maxVotes = activeShareAccounts != null ? activeShareAccounts : 0;
|
||||||
|
|
||||||
|
// Load candidates, capture old votes, accumulate post-wise totals
|
||||||
|
Map<Long, ElectionCandidate> candidateCache = new HashMap<>();
|
||||||
|
Map<Long, Integer> oldVoteCache = new HashMap<>();
|
||||||
|
Map<Long, Integer> postVoteTotals = new HashMap<>();
|
||||||
|
Map<Long, String> postNameCache = new HashMap<>();
|
||||||
|
|
||||||
|
for (ManualVoteCountEntry entry : counts) {
|
||||||
|
ElectionCandidate candidate = candidateRepository.findById(entry.getCandidateId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + entry.getCandidateId()));
|
||||||
|
candidateCache.put(entry.getCandidateId(), candidate);
|
||||||
|
oldVoteCache.put(entry.getCandidateId(), candidate.getManualVoteCount() != null ? candidate.getManualVoteCount() : 0);
|
||||||
|
|
||||||
|
Long postId = candidate.getElectionPost().getId();
|
||||||
|
postNameCache.put(postId, candidate.getElectionPost().getPostName());
|
||||||
|
int votes = entry.getVoteCount() != null ? entry.getVoteCount() : 0;
|
||||||
|
postVoteTotals.merge(postId, votes, Integer::sum);
|
||||||
}
|
}
|
||||||
|
|
||||||
ElectionPost post = electionPostRepository.findById(request.getPostId())
|
// Validate: no post's total votes can exceed active share accounts
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + request.getPostId()));
|
for (Map.Entry<Long, Integer> e : postVoteTotals.entrySet()) {
|
||||||
|
if (e.getValue() > maxVotes) {
|
||||||
|
throw new VoteCountExceedsShareAccountsException(
|
||||||
|
postNameCache.get(e.getKey()), e.getValue(), maxVotes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ElectionCandidate candidate = candidateRepository.findById(request.getCandidateId())
|
// Persist and build details JSON
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + request.getCandidateId()));
|
boolean isFirstSave = oldVoteCache.values().stream().allMatch(v -> v == 0);
|
||||||
|
StringBuilder details = new StringBuilder("[");
|
||||||
ElectionVote vote = ElectionVote.cast(election, post, request.getVoterMemberId(),
|
for (int i = 0; i < counts.size(); i++) {
|
||||||
candidate, request.getBoothId(), null);
|
ManualVoteCountEntry entry = counts.get(i);
|
||||||
voteRepository.save(vote);
|
ElectionCandidate candidate = candidateCache.get(entry.getCandidateId());
|
||||||
|
int oldVotes = oldVoteCache.get(entry.getCandidateId());
|
||||||
|
int newVotes = entry.getVoteCount() != null ? entry.getVoteCount() : 0;
|
||||||
|
candidate.setManualVoteCount(newVotes);
|
||||||
|
candidateRepository.save(candidate);
|
||||||
|
if (i > 0) details.append(",");
|
||||||
|
details.append("{\"candidate\":\"").append(candidate.getCandidateName())
|
||||||
|
.append("\",\"post\":\"").append(candidate.getElectionPost().getPostName())
|
||||||
|
.append("\",\"votes\":").append(newVotes);
|
||||||
|
if (!isFirstSave) details.append(",\"previousVotes\":").append(oldVotes);
|
||||||
|
details.append("}");
|
||||||
|
}
|
||||||
|
details.append("]");
|
||||||
|
return details.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -463,18 +523,43 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
|
|||||||
|
|
||||||
List<ElectionPost> posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId);
|
List<ElectionPost> posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId);
|
||||||
for (ElectionPost post : posts) {
|
for (ElectionPost post : posts) {
|
||||||
List<Object[]> voteCounts = voteRepository.countVotesByCandidate(electionId, post.getId());
|
List<ElectionCandidate> candidates = candidateRepository
|
||||||
int rank = 1;
|
.findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(electionId, post.getId());
|
||||||
for (Object[] row : voteCounts) {
|
|
||||||
Long candidateId = (Long) row[0];
|
if (candidates.size() == 1) {
|
||||||
Long count = (Long) row[1];
|
// Unopposed — single candidate automatically wins without voting
|
||||||
ElectionCandidate candidate = candidateRepository.findById(candidateId).orElse(null);
|
ElectionCandidate candidate = candidates.get(0);
|
||||||
if (candidate == null) continue;
|
int votes = candidate.getManualVoteCount() != null ? candidate.getManualVoteCount() : 0;
|
||||||
String result = rank <= post.getTotalSeats() ? "WON" : "LOST";
|
ElectionResult r = ElectionResult.create(election, post, candidate, votes, "WON", 1, declaredBy);
|
||||||
ElectionResult r = ElectionResult.create(election, post, candidate,
|
|
||||||
count.intValue(), result, rank, declaredBy);
|
|
||||||
resultRepository.save(r);
|
resultRepository.save(r);
|
||||||
rank++;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasManualCounts = candidates.stream().anyMatch(c -> c.getManualVoteCount() != null);
|
||||||
|
|
||||||
|
if (hasManualCounts) {
|
||||||
|
candidates.sort((a, b) -> {
|
||||||
|
int av = a.getManualVoteCount() != null ? a.getManualVoteCount() : 0;
|
||||||
|
int bv = b.getManualVoteCount() != null ? b.getManualVoteCount() : 0;
|
||||||
|
return Integer.compare(bv, av);
|
||||||
|
});
|
||||||
|
int rank = 1;
|
||||||
|
for (ElectionCandidate candidate : candidates) {
|
||||||
|
int votes = candidate.getManualVoteCount() != null ? candidate.getManualVoteCount() : 0;
|
||||||
|
String result = rank <= post.getTotalSeats() ? "WON" : "LOST";
|
||||||
|
ElectionResult r = ElectionResult.create(election, post, candidate, votes, result, rank, declaredBy);
|
||||||
|
resultRepository.save(r);
|
||||||
|
rank++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: no manual counts entered — rank all candidates with 0 votes
|
||||||
|
int rank = 1;
|
||||||
|
for (ElectionCandidate candidate : candidates) {
|
||||||
|
String result = rank <= post.getTotalSeats() ? "WON" : "LOST";
|
||||||
|
ElectionResult r = ElectionResult.create(election, post, candidate, 0, result, rank, declaredBy);
|
||||||
|
resultRepository.save(r);
|
||||||
|
rank++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -371,4 +371,7 @@
|
|||||||
<include file="parts/0341_election_audit_log.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0341_election_audit_log.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0342_election_post_add_eligibility_columns.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0342_election_post_add_eligibility_columns.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0343_client_sub_caste_column.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0343_client_sub_caste_column.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0344_election_candidate_manual_vote_count.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0345_election_candidate_manual_vote_count_drop_default.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0346_election_audit_log_description_to_text.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<changeSet id="0344_election_candidate_add_manual_vote_count" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_election_candidate" columnName="manual_vote_count"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_election_candidate">
|
||||||
|
<column name="manual_vote_count" type="INT">
|
||||||
|
<constraints nullable="true"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<changeSet id="0345_election_candidate_manual_vote_count_drop_default" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<columnExists tableName="m_election_candidate" columnName="manual_vote_count"/>
|
||||||
|
</preConditions>
|
||||||
|
<sql>ALTER TABLE m_election_candidate ALTER COLUMN manual_vote_count DROP DEFAULT;</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<changeSet id="0346_election_audit_log_description_to_text" author="CBS">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<columnExists tableName="m_election_audit_log" columnName="description"/>
|
||||||
|
</preConditions>
|
||||||
|
<sql>ALTER TABLE m_election_audit_log ALTER COLUMN description TYPE TEXT;</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
Reference in New Issue
Block a user