diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java new file mode 100644 index 000000000..7e88b8eed --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java @@ -0,0 +1,65 @@ +package org.apache.fineract.portfolio.election.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService; +import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@Path("/v1/election") +@Tag(name = "Election Candidates", description = "Manage approved election candidates") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class ElectionCandidateApiResource { + + private final ElectionReadPlatformService readService; + private final ElectionWritePlatformService writeService; + private final PlatformSecurityContext context; + + @GET + @Path("/{electionId}/candidates") + @Operation(summary = "Get all approved candidates for an election") + public Response getCandidates(@PathParam("electionId") Long electionId, + @QueryParam("postId") Long postId) { + if (postId != null) { + return Response.ok(readService.retrieveCandidatesByPost(electionId, postId)).build(); + } + return Response.ok(readService.retrieveCandidatesByElection(electionId)).build(); + } + + @POST + @Path("/{electionId}/nomination/{nominationId}/approve-candidate") + @Operation(summary = "Approve nomination and add to candidates list") + public Response approveCandidate(@PathParam("electionId") Long electionId, + @PathParam("nominationId") Long nominationId) { + Long approvedBy = context.authenticatedUser().getId(); + Long candidateId = writeService.approveCandidate(nominationId, approvedBy); + return Response.ok(Map.of("resourceId", candidateId)).build(); + } + + @DELETE + @Path("/{electionId}/candidate/{candidateId}") + @Operation(summary = "Remove a candidate from the election") + public Response removeCandidate(@PathParam("electionId") Long electionId, + @PathParam("candidateId") Long candidateId, + @QueryParam("reason") String reason) { + writeService.removeCandidate(candidateId, reason); + return Response.ok(Map.of("resourceId", candidateId, "status", "REMOVED")).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java new file mode 100644 index 000000000..27a046c99 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java @@ -0,0 +1,106 @@ +package org.apache.fineract.portfolio.election.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.election.data.ElectionConfigRequest; +import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService; +import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@Path("/v1/election/config") +@Tag(name = "Election Configuration", description = "Manage cooperative election setup") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class ElectionConfigApiResource { + + private final ElectionReadPlatformService readService; + private final ElectionWritePlatformService writeService; + private final ObjectMapper objectMapper; + + @GET + @Operation(summary = "List all elections") + public Response listElections() { + return Response.ok(readService.retrieveAllElections()).build(); + } + + @GET + @Path("/{electionId}") + @Operation(summary = "Get election details") + public Response getElection(@PathParam("electionId") Long electionId) { + return Response.ok(readService.retrieveElection(electionId)).build(); + } + + @POST + @Operation(summary = "Create a new election") + public Response createElection(ElectionConfigRequest request) { + Long id = writeService.createElection(request); + return Response.ok(Map.of("resourceId", id)).build(); + } + + @PUT + @Path("/{electionId}") + @Operation(summary = "Update election configuration") + public Response updateElection(@PathParam("electionId") Long electionId, + ElectionConfigRequest request) { + writeService.updateElection(electionId, request); + return Response.ok(Map.of("resourceId", electionId)).build(); + } + + @POST + @Path("/{electionId}/activate") + @Operation(summary = "Activate election to open nominations") + public Response activateElection(@PathParam("electionId") Long electionId) { + writeService.activateElection(electionId); + return Response.ok(Map.of("resourceId", electionId, "status", "ACTIVE")).build(); + } + + @POST + @Path("/{electionId}/close-nominations") + @Operation(summary = "Close nomination window") + public Response closeNominations(@PathParam("electionId") Long electionId) { + writeService.closeNominations(electionId); + return Response.ok(Map.of("resourceId", electionId, "status", "NOMINATION_CLOSED")).build(); + } + + @POST + @Path("/{electionId}/open-voting") + @Operation(summary = "Open voting") + public Response openVoting(@PathParam("electionId") Long electionId) { + writeService.openVoting(electionId); + return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_OPEN")).build(); + } + + @POST + @Path("/{electionId}/close-voting") + @Operation(summary = "Close voting") + public Response closeVoting(@PathParam("electionId") Long electionId) { + writeService.closeVoting(electionId); + return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_CLOSED")).build(); + } + + @POST + @Path("/{electionId}/cancel") + @Operation(summary = "Cancel election") + public Response cancelElection(@PathParam("electionId") Long electionId, + @QueryParam("reason") String reason) { + writeService.cancelElection(electionId, reason); + return Response.ok(Map.of("resourceId", electionId, "status", "CANCELLED")).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java new file mode 100644 index 000000000..5384af43e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java @@ -0,0 +1,112 @@ +package org.apache.fineract.portfolio.election.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest; +import org.apache.fineract.portfolio.election.data.ElectionNominationRequest; +import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService; +import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@Path("/v1/election") +@Tag(name = "Election Nomination", description = "Manage election nominations and approval workflow") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class ElectionNominationApiResource { + + private final ElectionReadPlatformService readService; + private final ElectionWritePlatformService writeService; + + @POST + @Path("/{electionId}/nomination") + @Operation(summary = "Submit a nomination for election") + public Response submitNomination(@PathParam("electionId") Long electionId, + ElectionNominationRequest request) { + request.setElectionId(electionId); + Long id = writeService.submitNomination(request); + return Response.ok(Map.of("resourceId", id)).build(); + } + + @GET + @Path("/{electionId}/nominations") + @Operation(summary = "List all nominations for an election") + public Response listNominations(@PathParam("electionId") Long electionId, + @QueryParam("status") String status) { + return Response.ok(readService.retrieveNominationsByElection(electionId, status)).build(); + } + + @GET + @Path("/{electionId}/nomination/{nominationId}") + @Operation(summary = "Get nomination details with eligibility and CEO check") + public Response getNomination(@PathParam("electionId") Long electionId, + @PathParam("nominationId") Long nominationId) { + return Response.ok(readService.retrieveNomination(nominationId)).build(); + } + + @POST + @Path("/{electionId}/nomination/{nominationId}/withdraw") + @Operation(summary = "Withdraw nomination") + public Response withdrawNomination(@PathParam("electionId") Long electionId, + @PathParam("nominationId") Long nominationId) { + writeService.withdrawNomination(nominationId); + return Response.ok(Map.of("resourceId", nominationId, "status", "WITHDRAWN")).build(); + } + + @POST + @Path("/{electionId}/nomination/{nominationId}/eligibility-check") + @Operation(summary = "Run automated eligibility check (age, gender, caste, shares, area)") + public Response runEligibilityCheck(@PathParam("electionId") Long electionId, + @PathParam("nominationId") Long nominationId) { + writeService.runEligibilityCheck(nominationId); + return Response.ok(readService.retrieveNomination(nominationId)).build(); + } + + @POST + @Path("/{electionId}/nomination/{nominationId}/ceo-precheck") + @Operation(summary = "Run CEO pre-check (director loan, relation accounts)") + public Response runCeoPreCheck(@PathParam("electionId") Long electionId, + @PathParam("nominationId") Long nominationId) { + writeService.runCeoPreCheck(nominationId); + return Response.ok(readService.retrieveNomination(nominationId)).build(); + } + + @POST + @Path("/{electionId}/nomination/{nominationId}/no-due-certificate") + @Operation(summary = "Generate No Due Certificate for nominee") + public Response generateNoDueCertificate(@PathParam("electionId") Long electionId, + @PathParam("nominationId") Long nominationId) { + String certNumber = writeService.generateNoDueCertificate(nominationId); + return Response.ok(Map.of("certificateNumber", certNumber)).build(); + } + + @POST + @Path("/{electionId}/nomination/{nominationId}/approval-action") + @Operation(summary = "Process approval step (approve or reject)") + public Response processApprovalStep(@PathParam("electionId") Long electionId, + @PathParam("nominationId") Long nominationId, + ElectionApprovalActionRequest request) { + writeService.processApprovalStep(nominationId, request); + return Response.ok(readService.retrieveNomination(nominationId)).build(); + } + + @GET + @Path("/nominations/member/{memberId}") + @Operation(summary = "Get all nominations submitted by a member") + public Response getNominationsByMember(@PathParam("memberId") Long memberId) { + return Response.ok(readService.retrieveNominationsByMember(memberId)).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java new file mode 100644 index 000000000..fadd4d96d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java @@ -0,0 +1,60 @@ +package org.apache.fineract.portfolio.election.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.election.data.ElectionVoteRequest; +import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService; +import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@Path("/v1/election") +@Tag(name = "Election Voting", description = "Election day voting and results") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class ElectionVotingApiResource { + + private final ElectionReadPlatformService readService; + private final ElectionWritePlatformService writeService; + private final PlatformSecurityContext context; + + @POST + @Path("/{electionId}/vote") + @Operation(summary = "Cast a vote for a candidate") + public Response castVote(@PathParam("electionId") Long electionId, + ElectionVoteRequest request) { + request.setElectionId(electionId); + writeService.castVote(request); + return Response.ok(Map.of("message", "Vote cast successfully")).build(); + } + + @POST + @Path("/{electionId}/declare-results") + @Operation(summary = "Declare election results") + public Response declareResults(@PathParam("electionId") Long electionId) { + Long declaredBy = context.authenticatedUser().getId(); + writeService.declareResults(electionId, declaredBy); + return Response.ok(readService.retrieveResultsByElection(electionId)).build(); + } + + @GET + @Path("/{electionId}/results") + @Operation(summary = "Get election results") + public Response getResults(@PathParam("electionId") Long electionId) { + return Response.ok(readService.retrieveResultsByElection(electionId)).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionApprovalActionRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionApprovalActionRequest.java new file mode 100644 index 000000000..2e9cc02b2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionApprovalActionRequest.java @@ -0,0 +1,19 @@ +package org.apache.fineract.portfolio.election.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ElectionApprovalActionRequest { + + private String action; + private String remarks; + private Boolean familyDetailsVerified; + private String familyDetailsJson; + private String rejectionReason; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionApprovalStepData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionApprovalStepData.java new file mode 100644 index 000000000..b467a6278 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionApprovalStepData.java @@ -0,0 +1,28 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ElectionApprovalStepData { + + private Long id; + private Long nominationId; + private Integer stepNumber; + private String stepName; + private String approverRole; + private Long approverUserId; + private String approverName; + private String status; + private String remarks; + private LocalDateTime actionDate; + private Boolean familyDetailsVerified; + private String familyDetailsJson; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java new file mode 100644 index 000000000..1ce5f569f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java @@ -0,0 +1,33 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ElectionCandidateData { + + private Long id; + private Long electionId; + private Long nominationId; + private Long postId; + private String postName; + private String candidateName; + private Integer serialNumber; + private String photoUrl; + private Boolean isActive; + private LocalDateTime approvedDate; + private String symbol; + private String briefProfile; + private String gender; + private String caste; + private String location; + private Integer shareCount; + private Integer voteCount; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCeoPreCheckData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCeoPreCheckData.java new file mode 100644 index 000000000..47952fd68 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCeoPreCheckData.java @@ -0,0 +1,33 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ElectionCeoPreCheckData { + + private Long id; + private Long nominationId; + private String directorLoanCheckResult; + private BigDecimal directorLoanOutstanding; + private String directorLoanRemarks; + private String relationAcCheckResult; + private String relationAccountsDetails; + private String relationAcRemarks; + private String noDueCheckResult; + private String noDueCertificateNumber; + private LocalDateTime noDueGeneratedDate; + private String overallResult; + private Long checkedBy; + private String checkedByName; + private LocalDateTime checkedDate; + private String remarks; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigData.java new file mode 100644 index 000000000..42fbed8ea --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigData.java @@ -0,0 +1,32 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ElectionConfigData { + + private Long id; + private String title; + private String description; + private String electionType; + private LocalDate nominationStartDate; + private LocalDate nominationEndDate; + private LocalDate electionDate; + private LocalDate resultDate; + private String status; + private Boolean isActive; + private String remarks; + private List posts; + private Long totalNominations; + private Long approvedNominations; + private Long pendingNominations; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigRequest.java new file mode 100644 index 000000000..7858fefd9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigRequest.java @@ -0,0 +1,26 @@ +package org.apache.fineract.portfolio.election.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ElectionConfigRequest { + + private String title; + private String description; + private String electionType; + private LocalDate nominationStartDate; + private LocalDate nominationEndDate; + private LocalDate electionDate; + private LocalDate resultDate; + private String remarks; + private List posts; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionEligibilityCheckData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionEligibilityCheckData.java new file mode 100644 index 000000000..e1a618a56 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionEligibilityCheckData.java @@ -0,0 +1,23 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ElectionEligibilityCheckData { + + private Long id; + private Long nominationId; + private String overallResult; + private Long checkedBy; + private String checkedByName; + private LocalDateTime checkedDate; + private String remarks; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionNominationData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionNominationData.java new file mode 100644 index 000000000..de0869a16 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionNominationData.java @@ -0,0 +1,48 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ElectionNominationData { + + private Long id; + private Long electionId; + private String electionTitle; + private Long postId; + private String postName; + private Long memberId; + private Long shareAccountId; + private String applicationNumber; + private String applicantName; + private LocalDate dateOfBirth; + private Integer age; + private String gender; + private String caste; + private String casteCategory; + private String location; + private String serviceDetails; + private String mobileNumber; + private String email; + private String address; + private String occupation; + private Integer shareCount; + private String status; + private String statusDisplayName; + private String rejectionReason; + private LocalDateTime submittedDate; + private Boolean declarationAccepted; + private Long photoDocumentId; + private ElectionEligibilityCheckData eligibilityCheck; + private ElectionCeoPreCheckData ceoPreCheck; + private List approvalSteps; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionNominationRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionNominationRequest.java new file mode 100644 index 000000000..0de61aebe --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionNominationRequest.java @@ -0,0 +1,34 @@ +package org.apache.fineract.portfolio.election.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ElectionNominationRequest { + + private Long electionId; + private Long postId; + private Long memberId; + private Long shareAccountId; + private String applicantName; + private LocalDate dateOfBirth; + private String gender; + private String caste; + private String casteCategory; + private String location; + private String serviceDetails; + private String mobileNumber; + private String email; + private String address; + private String occupation; + private Integer shareCount; + private Boolean declarationAccepted; + private Long photoDocumentId; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionPostData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionPostData.java new file mode 100644 index 000000000..69c496f8d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionPostData.java @@ -0,0 +1,21 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ElectionPostData { + + private Long id; + private Long electionId; + private String postName; + private Integer totalSeats; + private Boolean isActive; + private Long filledSeats; + private Long remainingSeats; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionPostRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionPostRequest.java new file mode 100644 index 000000000..384765f58 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionPostRequest.java @@ -0,0 +1,16 @@ +package org.apache.fineract.portfolio.election.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ElectionPostRequest { + + private String postName; + private Integer totalSeats; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionResultData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionResultData.java new file mode 100644 index 000000000..b57c3328a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionResultData.java @@ -0,0 +1,29 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ElectionResultData { + + private Long id; + private Long electionId; + private String electionTitle; + private Long postId; + private String postName; + private Long candidateId; + private String candidateName; + private Integer voteCount; + private String result; + private Integer rankPosition; + private LocalDateTime declaredDate; + private List postResults; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionVoteRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionVoteRequest.java new file mode 100644 index 000000000..d91eb1539 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionVoteRequest.java @@ -0,0 +1,19 @@ +package org.apache.fineract.portfolio.election.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ElectionVoteRequest { + + private Long electionId; + private Long postId; + private Long voterMemberId; + private Long candidateId; + private String boothId; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ApprovalStepStatus.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ApprovalStepStatus.java new file mode 100644 index 000000000..b6915da4f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ApprovalStepStatus.java @@ -0,0 +1,8 @@ +package org.apache.fineract.portfolio.election.domain; + +public enum ApprovalStepStatus { + PENDING, + APPROVED, + REJECTED, + SKIPPED +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CheckResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CheckResult.java new file mode 100644 index 000000000..a5207bfbc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CheckResult.java @@ -0,0 +1,8 @@ +package org.apache.fineract.portfolio.election.domain; + +public enum CheckResult { + PASS, + FAIL, + PENDING, + WAIVED +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStep.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStep.java new file mode 100644 index 000000000..e727a3a08 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStep.java @@ -0,0 +1,77 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_election_approval_step") +@Getter +@Setter +@NoArgsConstructor +public class ElectionApprovalStep extends AbstractPersistableCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "nomination_id", nullable = false) + private ElectionNomination nomination; + + @Column(name = "step_number", nullable = false) + private Integer stepNumber; + + @Column(name = "step_name", nullable = false, length = 200) + private String stepName; + + @Column(name = "approver_role", length = 100) + private String approverRole; + + @Column(name = "approver_user_id") + private Long approverUserId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 50) + private ApprovalStepStatus status = ApprovalStepStatus.PENDING; + + @Column(name = "remarks", columnDefinition = "TEXT") + private String remarks; + + @Column(name = "action_date") + private LocalDateTime actionDate; + + @Column(name = "family_details_verified", nullable = false) + private Boolean familyDetailsVerified = false; + + @Column(name = "family_details_json", columnDefinition = "TEXT") + private String familyDetailsJson; + + public static ElectionApprovalStep create(ElectionNomination nomination, + int stepNumber, String stepName, String approverRole) { + ElectionApprovalStep step = new ElectionApprovalStep(); + step.nomination = nomination; + step.stepNumber = stepNumber; + step.stepName = stepName; + step.approverRole = approverRole; + step.status = ApprovalStepStatus.PENDING; + step.familyDetailsVerified = false; + return step; + } + + public void approve(Long approverUserId, String remarks, boolean familyVerified, String familyJson) { + this.approverUserId = approverUserId; + this.status = ApprovalStepStatus.APPROVED; + this.remarks = remarks; + this.actionDate = LocalDateTime.now(); + this.familyDetailsVerified = familyVerified; + this.familyDetailsJson = familyJson; + } + + public void reject(Long approverUserId, String remarks) { + this.approverUserId = approverUserId; + this.status = ApprovalStepStatus.REJECTED; + this.remarks = remarks; + this.actionDate = LocalDateTime.now(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStepRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStepRepository.java new file mode 100644 index 000000000..a32ea9cc8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionApprovalStepRepository.java @@ -0,0 +1,23 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ElectionApprovalStepRepository extends JpaRepository { + + List findByNominationIdOrderByStepNumberAsc(Long nominationId); + + Optional findByNominationIdAndStepNumber(Long nominationId, int stepNumber); + + @Query("SELECT s FROM ElectionApprovalStep s WHERE s.nomination.id = :nominationId " + + "AND s.status = org.apache.fineract.portfolio.election.domain.ApprovalStepStatus.PENDING ORDER BY s.stepNumber ASC") + Optional findNextPendingStep(@Param("nominationId") Long nominationId); + + boolean existsByNominationIdAndStatus(Long nominationId, ApprovalStepStatus status); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java new file mode 100644 index 000000000..656021dc4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java @@ -0,0 +1,75 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_election_candidate") +@Getter +@Setter +@NoArgsConstructor +public class ElectionCandidate extends AbstractPersistableCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "election_id", nullable = false) + private ElectionConfig electionConfig; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "nomination_id", nullable = false, unique = true) + private ElectionNomination nomination; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private ElectionPost electionPost; + + @Column(name = "candidate_name", nullable = false, length = 200) + private String candidateName; + + @Column(name = "serial_number") + private Integer serialNumber; + + @Column(name = "photo_url", length = 500) + private String photoUrl; + + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + + @Column(name = "approved_by") + private Long approvedBy; + + @Column(name = "approved_date") + private LocalDateTime approvedDate; + + @Column(name = "symbol", length = 100) + private String symbol; + + @Column(name = "brief_profile", columnDefinition = "TEXT") + private String briefProfile; + + public static ElectionCandidate create(ElectionConfig electionConfig, + ElectionNomination nomination, + ElectionPost electionPost, + String candidateName, + Integer serialNumber, + Long approvedBy) { + ElectionCandidate candidate = new ElectionCandidate(); + candidate.electionConfig = electionConfig; + candidate.nomination = nomination; + candidate.electionPost = electionPost; + candidate.candidateName = candidateName; + candidate.serialNumber = serialNumber; + candidate.approvedBy = approvedBy; + candidate.approvedDate = LocalDateTime.now(); + candidate.isActive = true; + return candidate; + } + + public void deactivate() { + this.isActive = false; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidateRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidateRepository.java new file mode 100644 index 000000000..eb7caca23 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidateRepository.java @@ -0,0 +1,29 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ElectionCandidateRepository extends JpaRepository { + + List findByElectionConfigIdAndIsActiveTrue(Long electionId); + + List findByElectionConfigIdAndElectionPostIdAndIsActiveTrue( + Long electionId, Long postId); + + Optional findByNominationId(Long nominationId); + + @Query("SELECT c FROM ElectionCandidate c WHERE c.electionConfig.id = :electionId " + + "AND c.isActive = true ORDER BY c.electionPost.postName, c.serialNumber") + List findAllActiveCandidates(@Param("electionId") Long electionId); + + @Query("SELECT MAX(c.serialNumber) FROM ElectionCandidate c " + + "WHERE c.electionConfig.id = :electionId AND c.electionPost.id = :postId") + Optional findMaxSerialNumberByElectionAndPost( + @Param("electionId") Long electionId, @Param("postId") Long postId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheck.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheck.java new file mode 100644 index 000000000..837fc2366 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheck.java @@ -0,0 +1,103 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_election_ceo_precheck") +@Getter +@Setter +@NoArgsConstructor +public class ElectionCeoPrecheck extends AbstractPersistableCustom { + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "nomination_id", nullable = false, unique = true) + private ElectionNomination nomination; + + @Enumerated(EnumType.STRING) + @Column(name = "director_loan_check_result", length = 20) + private CheckResult directorLoanCheckResult = CheckResult.PENDING; + + @Column(name = "director_loan_outstanding", precision = 19, scale = 4) + private BigDecimal directorLoanOutstanding = BigDecimal.ZERO; + + @Column(name = "director_loan_remarks", columnDefinition = "TEXT") + private String directorLoanRemarks; + + @Enumerated(EnumType.STRING) + @Column(name = "relation_ac_check_result", length = 20) + private CheckResult relationAcCheckResult = CheckResult.PENDING; + + @Column(name = "relation_accounts_details", columnDefinition = "TEXT") + private String relationAccountsDetails; + + @Column(name = "relation_ac_remarks", columnDefinition = "TEXT") + private String relationAcRemarks; + + @Enumerated(EnumType.STRING) + @Column(name = "no_due_check_result", length = 20) + private CheckResult noDueCheckResult = CheckResult.PENDING; + + @Column(name = "no_due_certificate_number", length = 100) + private String noDueCertificateNumber; + + @Column(name = "no_due_generated_date") + private LocalDateTime noDueGeneratedDate; + + @Enumerated(EnumType.STRING) + @Column(name = "overall_result", length = 20) + private CheckResult overallResult = CheckResult.PENDING; + + @Column(name = "checked_by") + private Long checkedBy; + + @Column(name = "checked_date") + private LocalDateTime checkedDate; + + @Column(name = "remarks", columnDefinition = "TEXT") + private String remarks; + + public static ElectionCeoPrecheck create(ElectionNomination nomination) { + ElectionCeoPrecheck precheck = new ElectionCeoPrecheck(); + precheck.nomination = nomination; + precheck.overallResult = CheckResult.PENDING; + precheck.directorLoanCheckResult = CheckResult.PENDING; + precheck.relationAcCheckResult = CheckResult.PENDING; + precheck.noDueCheckResult = CheckResult.PENDING; + return precheck; + } + + public void completeDirectorLoanCheck(CheckResult result, BigDecimal outstanding, String remarks) { + this.directorLoanCheckResult = result; + this.directorLoanOutstanding = outstanding != null ? outstanding : BigDecimal.ZERO; + this.directorLoanRemarks = remarks; + } + + public void completeRelationAcCheck(CheckResult result, String relationDetails, String remarks) { + this.relationAcCheckResult = result; + this.relationAccountsDetails = relationDetails; + this.relationAcRemarks = remarks; + } + + public void generateNoDueCertificate(String certificateNumber) { + this.noDueCertificateNumber = certificateNumber; + this.noDueGeneratedDate = LocalDateTime.now(); + this.noDueCheckResult = CheckResult.PASS; + } + + public void finalise(Long checkedBy, String remarks) { + this.checkedBy = checkedBy; + this.checkedDate = LocalDateTime.now(); + this.remarks = remarks; + boolean allPass = directorLoanCheckResult == CheckResult.PASS + && relationAcCheckResult == CheckResult.PASS + && noDueCheckResult == CheckResult.PASS; + this.overallResult = allPass ? CheckResult.PASS : CheckResult.FAIL; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheckRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheckRepository.java new file mode 100644 index 000000000..dcd3cd1f3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCeoPrecheckRepository.java @@ -0,0 +1,12 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ElectionCeoPrecheckRepository extends JpaRepository { + + Optional findByNominationId(Long nominationId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfig.java new file mode 100644 index 000000000..4d14da589 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfig.java @@ -0,0 +1,111 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "m_election_config") +@Getter +@Setter +@NoArgsConstructor +public class ElectionConfig extends AbstractAuditableWithUTCDateTimeCustom { + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "election_type", nullable = false, length = 50) + private ElectionType electionType; + + @Column(name = "nomination_start_date", nullable = false) + private LocalDate nominationStartDate; + + @Column(name = "nomination_end_date", nullable = false) + private LocalDate nominationEndDate; + + @Column(name = "election_date", nullable = false) + private LocalDate electionDate; + + @Column(name = "result_date") + private LocalDate resultDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 50) + private ElectionStatus status = ElectionStatus.DRAFT; + + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + + @Column(name = "remarks", columnDefinition = "TEXT") + private String remarks; + + @OneToMany(mappedBy = "electionConfig", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List posts = new ArrayList<>(); + + private ElectionConfig(String title, String description, ElectionType electionType, + LocalDate nominationStartDate, LocalDate nominationEndDate, + LocalDate electionDate, LocalDate resultDate) { + this.title = title; + this.description = description; + this.electionType = electionType; + this.nominationStartDate = nominationStartDate; + this.nominationEndDate = nominationEndDate; + this.electionDate = electionDate; + this.resultDate = resultDate; + this.status = ElectionStatus.DRAFT; + this.isActive = true; + } + + public static ElectionConfig create(String title, String description, ElectionType electionType, + LocalDate nominationStartDate, LocalDate nominationEndDate, + LocalDate electionDate, LocalDate resultDate) { + return new ElectionConfig(title, description, electionType, nominationStartDate, + nominationEndDate, electionDate, resultDate); + } + + public boolean isNominationOpen() { + LocalDate today = LocalDate.now(); + return status == ElectionStatus.ACTIVE + && !today.isBefore(nominationStartDate) + && !today.isAfter(nominationEndDate); + } + + public boolean isVotingOpen() { + return status == ElectionStatus.VOTING_OPEN; + } + + public void activate() { + this.status = ElectionStatus.ACTIVE; + } + + public void closeNominations() { + this.status = ElectionStatus.NOMINATION_CLOSED; + } + + public void openVoting() { + this.status = ElectionStatus.VOTING_OPEN; + } + + public void closeVoting() { + this.status = ElectionStatus.VOTING_CLOSED; + } + + public void declareResult() { + this.status = ElectionStatus.RESULT_DECLARED; + } + + public void cancel(String reason) { + this.status = ElectionStatus.CANCELLED; + this.remarks = reason; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java new file mode 100644 index 000000000..3c523b693 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java @@ -0,0 +1,30 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface ElectionConfigRepository extends JpaRepository { + + List findByIsActiveTrue(); + + List findByStatus(ElectionStatus status); + + List findByElectionType(ElectionType electionType); + + @Query("SELECT e FROM ElectionConfig e WHERE e.isActive = true AND e.status IN :statuses ORDER BY e.electionDate DESC") + List findActiveByStatuses(@Param("statuses") List statuses); + + @Query("SELECT e FROM ElectionConfig e WHERE e.status = org.apache.fineract.portfolio.election.domain.ElectionStatus.ACTIVE AND CURRENT_DATE BETWEEN e.nominationStartDate AND e.nominationEndDate") + List findOpenForNomination(); + + Optional findByIdAndIsActiveTrue(Long id); + + boolean existsByStatusIn(Set statuses); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheck.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheck.java new file mode 100644 index 000000000..7e9b491b6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheck.java @@ -0,0 +1,48 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_election_eligibility_check") +@Getter +@Setter +@NoArgsConstructor +public class ElectionEligibilityCheck extends AbstractPersistableCustom { + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "nomination_id", nullable = false, unique = true) + private ElectionNomination nomination; + + @Enumerated(EnumType.STRING) + @Column(name = "overall_result", length = 20) + private CheckResult overallResult = CheckResult.PENDING; + + @Column(name = "checked_by") + private Long checkedBy; + + @Column(name = "checked_date") + private LocalDateTime checkedDate; + + @Column(name = "remarks", columnDefinition = "TEXT") + private String remarks; + + public static ElectionEligibilityCheck create(ElectionNomination nomination) { + ElectionEligibilityCheck check = new ElectionEligibilityCheck(); + check.nomination = nomination; + check.overallResult = CheckResult.PENDING; + return check; + } + + public void complete(Long checkedBy, String remarks, boolean passed) { + this.checkedBy = checkedBy; + this.checkedDate = LocalDateTime.now(); + this.remarks = remarks; + this.overallResult = passed ? CheckResult.PASS : CheckResult.FAIL; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheckRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheckRepository.java new file mode 100644 index 000000000..9e08f1277 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionEligibilityCheckRepository.java @@ -0,0 +1,12 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ElectionEligibilityCheckRepository extends JpaRepository { + + Optional findByNominationId(Long nominationId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNomination.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNomination.java new file mode 100644 index 000000000..94408c4f5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNomination.java @@ -0,0 +1,160 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "m_election_nomination", + uniqueConstraints = @UniqueConstraint(columnNames = {"election_id", "member_id", "post_id"})) +@Getter +@Setter +@NoArgsConstructor +public class ElectionNomination extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "election_id", nullable = false) + private ElectionConfig electionConfig; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private ElectionPost electionPost; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "share_account_id") + private Long shareAccountId; + + @Column(name = "application_number", unique = true, length = 50) + private String applicationNumber; + + @Column(name = "applicant_name", nullable = false, length = 200) + private String applicantName; + + @Column(name = "date_of_birth") + private LocalDate dateOfBirth; + + @Column(name = "gender", length = 20) + private String gender; + + @Column(name = "caste", length = 100) + private String caste; + + @Column(name = "caste_category", length = 100) + private String casteCategory; + + @Column(name = "location", length = 200) + private String location; + + @Column(name = "service_details", length = 500) + private String serviceDetails; + + @Column(name = "mobile_number", length = 20) + private String mobileNumber; + + @Column(name = "email", length = 150) + private String email; + + @Column(name = "address", columnDefinition = "TEXT") + private String address; + + @Column(name = "occupation", length = 200) + private String occupation; + + @Column(name = "share_count") + private Integer shareCount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 50) + private NominationStatus status = NominationStatus.DRAFT; + + @Column(name = "rejection_reason", columnDefinition = "TEXT") + private String rejectionReason; + + @Column(name = "submitted_date") + private LocalDateTime submittedDate; + + @Column(name = "declaration_accepted", nullable = false) + private Boolean declarationAccepted = false; + + @Column(name = "photo_document_id") + private Long photoDocumentId; + + @OneToMany(mappedBy = "nomination", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List approvalSteps = new ArrayList<>(); + + public static ElectionNomination create(ElectionConfig electionConfig, ElectionPost electionPost, + Long memberId, Long shareAccountId, String applicantName, + LocalDate dateOfBirth, String gender, String caste, + String casteCategory, String location, String serviceDetails, + String mobileNumber, String email, String address, + String occupation, Integer shareCount) { + ElectionNomination nomination = new ElectionNomination(); + nomination.electionConfig = electionConfig; + nomination.electionPost = electionPost; + nomination.memberId = memberId; + nomination.shareAccountId = shareAccountId; + nomination.applicantName = applicantName; + nomination.dateOfBirth = dateOfBirth; + nomination.gender = gender; + nomination.caste = caste; + nomination.casteCategory = casteCategory; + nomination.location = location; + nomination.serviceDetails = serviceDetails; + nomination.mobileNumber = mobileNumber; + nomination.email = email; + nomination.address = address; + nomination.occupation = occupation; + nomination.shareCount = shareCount; + nomination.status = NominationStatus.DRAFT; + return nomination; + } + + public void submit(String applicationNumber) { + this.applicationNumber = applicationNumber; + this.status = NominationStatus.SUBMITTED; + this.submittedDate = LocalDateTime.now(); + } + + public void markUnderReview() { + this.status = NominationStatus.UNDER_REVIEW; + } + + public void sendToCeoCheck() { + this.status = NominationStatus.CEO_CHECK_PENDING; + } + + public void ceoApprove() { + this.status = NominationStatus.CEO_APPROVED; + } + + public void ceoReject(String reason) { + this.status = NominationStatus.CEO_REJECTED; + this.rejectionReason = reason; + } + + public void startApprovalWorkflow() { + this.status = NominationStatus.APPROVAL_PENDING; + } + + public void approve() { + this.status = NominationStatus.APPROVED; + } + + public void reject(String reason) { + this.status = NominationStatus.REJECTED; + this.rejectionReason = reason; + } + + public void withdraw() { + this.status = NominationStatus.WITHDRAWN; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNominationRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNominationRepository.java new file mode 100644 index 000000000..e159067aa --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionNominationRepository.java @@ -0,0 +1,35 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ElectionNominationRepository extends JpaRepository { + + List findByElectionConfigId(Long electionId); + + List findByElectionConfigIdAndStatus(Long electionId, NominationStatus status); + + List findByMemberId(Long memberId); + + Optional findByElectionConfigIdAndMemberIdAndElectionPostId( + Long electionId, Long memberId, Long postId); + + boolean existsByElectionConfigIdAndMemberIdAndElectionPostId( + Long electionId, Long memberId, Long postId); + + Optional findByApplicationNumber(String applicationNumber); + + @Query("SELECT n FROM ElectionNomination n WHERE n.electionConfig.id = :electionId " + + "AND n.status NOT IN (org.apache.fineract.portfolio.election.domain.NominationStatus.REJECTED, org.apache.fineract.portfolio.election.domain.NominationStatus.WITHDRAWN)") + List findActiveNominationsByElection(@Param("electionId") Long electionId); + + @Query("SELECT COUNT(n) FROM ElectionNomination n WHERE n.electionPost.id = :postId " + + "AND n.status = org.apache.fineract.portfolio.election.domain.NominationStatus.APPROVED") + long countApprovedByPost(@Param("postId") Long postId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionPost.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionPost.java new file mode 100644 index 000000000..04f2b2bef --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionPost.java @@ -0,0 +1,37 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +@Entity +@Table(name = "m_election_post") +@Getter +@Setter +@NoArgsConstructor +public class ElectionPost extends AbstractPersistableCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "election_id", nullable = false) + private ElectionConfig electionConfig; + + @Column(name = "post_name", nullable = false, length = 200) + private String postName; + + @Column(name = "total_seats", nullable = false) + private Integer totalSeats = 1; + + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; + + public static ElectionPost create(ElectionConfig electionConfig, String postName, Integer totalSeats) { + ElectionPost post = new ElectionPost(); + post.electionConfig = electionConfig; + post.postName = postName; + post.totalSeats = totalSeats != null ? totalSeats : 1; + post.isActive = true; + return post; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionPostRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionPostRepository.java new file mode 100644 index 000000000..fe82c5d83 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionPostRepository.java @@ -0,0 +1,14 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ElectionPostRepository extends JpaRepository { + + List findByElectionConfigIdAndIsActiveTrue(Long electionId); + + List findByElectionConfigId(Long electionId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResult.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResult.java new file mode 100644 index 000000000..a15439866 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResult.java @@ -0,0 +1,59 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_election_result") +@Getter +@Setter +@NoArgsConstructor +public class ElectionResult extends AbstractPersistableCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "election_id", nullable = false) + private ElectionConfig electionConfig; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private ElectionPost electionPost; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "candidate_id", nullable = false) + private ElectionCandidate candidate; + + @Column(name = "vote_count", nullable = false) + private Integer voteCount = 0; + + @Column(name = "result", length = 20) + private String result; + + @Column(name = "rank_position") + private Integer rankPosition; + + @Column(name = "declared_by") + private Long declaredBy; + + @Column(name = "declared_date") + private LocalDateTime declaredDate; + + public static ElectionResult create(ElectionConfig electionConfig, ElectionPost post, + ElectionCandidate candidate, int voteCount, + String result, int rankPosition, Long declaredBy) { + ElectionResult r = new ElectionResult(); + r.electionConfig = electionConfig; + r.electionPost = post; + r.candidate = candidate; + r.voteCount = voteCount; + r.result = result; + r.rankPosition = rankPosition; + r.declaredBy = declaredBy; + r.declaredDate = LocalDateTime.now(); + return r; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResultRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResultRepository.java new file mode 100644 index 000000000..060eb1178 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResultRepository.java @@ -0,0 +1,16 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ElectionResultRepository extends JpaRepository { + + List findByElectionConfigIdOrderByElectionPostIdAscRankPositionAsc(Long electionId); + + List findByElectionConfigIdAndElectionPostId(Long electionId, Long postId); + + boolean existsByElectionConfigId(Long electionId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionStatus.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionStatus.java new file mode 100644 index 000000000..ee7a92b8a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionStatus.java @@ -0,0 +1,11 @@ +package org.apache.fineract.portfolio.election.domain; + +public enum ElectionStatus { + DRAFT, + ACTIVE, + NOMINATION_CLOSED, + VOTING_OPEN, + VOTING_CLOSED, + RESULT_DECLARED, + CANCELLED +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionType.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionType.java new file mode 100644 index 000000000..766e4fa70 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionType.java @@ -0,0 +1,7 @@ +package org.apache.fineract.portfolio.election.domain; + +public enum ElectionType { + DIRECTOR, + GENERAL, + BY_ELECTION +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVote.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVote.java new file mode 100644 index 000000000..329e56082 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVote.java @@ -0,0 +1,58 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_election_vote", + uniqueConstraints = @UniqueConstraint( + name = "uq_election_vote_member_post", + columnNames = {"election_id", "post_id", "voter_member_id"})) +@Getter +@Setter +@NoArgsConstructor +public class ElectionVote extends AbstractPersistableCustom { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "election_id", nullable = false) + private ElectionConfig electionConfig; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private ElectionPost electionPost; + + @Column(name = "voter_member_id", nullable = false) + private Long voterMemberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "candidate_id", nullable = false) + private ElectionCandidate candidate; + + @Column(name = "vote_timestamp", nullable = false) + private LocalDateTime voteTimestamp; + + @Column(name = "booth_id", length = 100) + private String boothId; + + @Column(name = "ip_address", length = 50) + private String ipAddress; + + public static ElectionVote cast(ElectionConfig electionConfig, ElectionPost post, + Long voterMemberId, ElectionCandidate candidate, + String boothId, String ipAddress) { + ElectionVote vote = new ElectionVote(); + vote.electionConfig = electionConfig; + vote.electionPost = post; + vote.voterMemberId = voterMemberId; + vote.candidate = candidate; + vote.voteTimestamp = LocalDateTime.now(); + vote.boothId = boothId; + vote.ipAddress = ipAddress; + return vote; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVoteRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVoteRepository.java new file mode 100644 index 000000000..b6d0e3af7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVoteRepository.java @@ -0,0 +1,25 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ElectionVoteRepository extends JpaRepository { + + boolean existsByElectionConfigIdAndElectionPostIdAndVoterMemberId( + Long electionId, Long postId, Long voterMemberId); + + @Query("SELECT v.candidate.id, COUNT(v) FROM ElectionVote v " + + "WHERE v.electionConfig.id = :electionId AND v.electionPost.id = :postId " + + "GROUP BY v.candidate.id ORDER BY COUNT(v) DESC") + List countVotesByCandidate(@Param("electionId") Long electionId, + @Param("postId") Long postId); + + @Query("SELECT COUNT(DISTINCT v.voterMemberId) FROM ElectionVote v " + + "WHERE v.electionConfig.id = :electionId") + long countTotalVotersByElection(@Param("electionId") Long electionId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/NominationStatus.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/NominationStatus.java new file mode 100644 index 000000000..00a4299fa --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/NominationStatus.java @@ -0,0 +1,14 @@ +package org.apache.fineract.portfolio.election.domain; + +public enum NominationStatus { + DRAFT, + SUBMITTED, + UNDER_REVIEW, + CEO_CHECK_PENDING, + CEO_APPROVED, + CEO_REJECTED, + APPROVAL_PENDING, + APPROVED, + REJECTED, + WITHDRAWN +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionAlreadyOngoingException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionAlreadyOngoingException.java new file mode 100644 index 000000000..4c73f43b7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionAlreadyOngoingException.java @@ -0,0 +1,11 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class ElectionAlreadyOngoingException extends AbstractPlatformDomainRuleException { + + public ElectionAlreadyOngoingException() { + super("error.msg.election.already.ongoing", + "An election is already in progress. Complete or cancel the existing election before creating a new one."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionNominationNotFoundException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionNominationNotFoundException.java new file mode 100644 index 000000000..5c74c8705 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionNominationNotFoundException.java @@ -0,0 +1,17 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +public class ElectionNominationNotFoundException extends AbstractPlatformResourceNotFoundException { + + public ElectionNominationNotFoundException(Long id) { + super("error.msg.election.nomination.not.found", + "Election nomination with id " + id + " does not exist", id); + } + + public ElectionNominationNotFoundException(String applicationNumber) { + super("error.msg.election.nomination.not.found", + "Election nomination with application number " + applicationNumber + " does not exist", + applicationNumber); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionNotActiveException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionNotActiveException.java new file mode 100644 index 000000000..4dc64994e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionNotActiveException.java @@ -0,0 +1,11 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class ElectionNotActiveException extends AbstractPlatformDomainRuleException { + + public ElectionNotActiveException(Long electionId, String currentStatus) { + super("error.msg.election.not.active", + "Election " + electionId + " is not open for nominations. Current status: " + currentStatus); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionNotFoundException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionNotFoundException.java new file mode 100644 index 000000000..19fbd59db --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionNotFoundException.java @@ -0,0 +1,10 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +public class ElectionNotFoundException extends AbstractPlatformResourceNotFoundException { + + public ElectionNotFoundException(Long id) { + super("error.msg.election.not.found", "Election with id " + id + " does not exist", id); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/IneligibleNominationException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/IneligibleNominationException.java new file mode 100644 index 000000000..ecfd9307e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/IneligibleNominationException.java @@ -0,0 +1,11 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class IneligibleNominationException extends AbstractPlatformDomainRuleException { + + public IneligibleNominationException(String reason) { + super("error.msg.election.nomination.ineligible", + "Nomination is ineligible: " + reason); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/InvalidNominationStateException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/InvalidNominationStateException.java new file mode 100644 index 000000000..a3239467e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/InvalidNominationStateException.java @@ -0,0 +1,11 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class InvalidNominationStateException extends AbstractPlatformDomainRuleException { + + public InvalidNominationStateException(String action, String currentStatus) { + super("error.msg.election.nomination.invalid.state", + "Cannot perform action '" + action + "' on nomination in status: " + currentStatus); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/NominationAlreadyExistsException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/NominationAlreadyExistsException.java new file mode 100644 index 000000000..d0cd18fda --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/NominationAlreadyExistsException.java @@ -0,0 +1,12 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class NominationAlreadyExistsException extends AbstractPlatformDomainRuleException { + + public NominationAlreadyExistsException(Long memberId, Long electionId, Long postId) { + super("error.msg.election.nomination.already.exists", + "Member " + memberId + " has already submitted a nomination for election " + + electionId + " post " + postId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteAlreadyCastException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteAlreadyCastException.java new file mode 100644 index 000000000..08844947e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteAlreadyCastException.java @@ -0,0 +1,11 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class VoteAlreadyCastException extends AbstractPlatformDomainRuleException { + + public VoteAlreadyCastException(Long memberId, Long postId) { + super("error.msg.election.vote.already.cast", + "Member " + memberId + " has already cast their vote for post " + postId); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckService.java new file mode 100644 index 000000000..82753e400 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckService.java @@ -0,0 +1,13 @@ +package org.apache.fineract.portfolio.election.service; + +import org.apache.fineract.portfolio.election.domain.ElectionCeoPrecheck; +import org.apache.fineract.portfolio.election.domain.ElectionNomination; + +public interface ElectionCeoPreCheckService { + + ElectionCeoPrecheck performDirectorLoanCheck(ElectionNomination nomination); + + ElectionCeoPrecheck performRelationAccountCheck(ElectionNomination nomination); + + String generateNoDueCertificateNumber(ElectionNomination nomination); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java new file mode 100644 index 000000000..fab9fa6b4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java @@ -0,0 +1,134 @@ +package org.apache.fineract.portfolio.election.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.election.domain.CheckResult; +import org.apache.fineract.portfolio.election.domain.ElectionCeoPrecheck; +import org.apache.fineract.portfolio.election.domain.ElectionCeoPrecheckRepository; +import org.apache.fineract.portfolio.election.domain.ElectionNomination; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ElectionCeoPreCheckServiceImpl implements ElectionCeoPreCheckService { + + private final ElectionCeoPrecheckRepository ceoPrecheckRepository; + private final ElectionNoDueCertificateService noDueCertificateService; + private final JdbcTemplate jdbcTemplate; + + @Override + @Transactional + public ElectionCeoPrecheck performDirectorLoanCheck(ElectionNomination nomination) { + ElectionCeoPrecheck precheck = ceoPrecheckRepository + .findByNominationId(nomination.getId()) + .orElse(ElectionCeoPrecheck.create(nomination)); + + try { + String query = """ + SELECT COALESCE(SUM(la.total_outstanding_derived), 0) AS outstanding + FROM m_loan la + INNER JOIN m_client c ON c.id = la.client_id + INNER JOIN m_share_account sa ON sa.client_id = c.id + WHERE sa.id = ? + AND la.loan_status_id = 300 + """; + + BigDecimal outstanding = jdbcTemplate.queryForObject( + query, BigDecimal.class, nomination.getShareAccountId()); + + if (outstanding == null) outstanding = BigDecimal.ZERO; + + boolean pass = outstanding.compareTo(BigDecimal.ZERO) == 0; + String remarks = pass + ? "No outstanding director loans found" + : "Outstanding loan amount: " + outstanding; + + precheck.completeDirectorLoanCheck( + pass ? CheckResult.PASS : CheckResult.FAIL, outstanding, remarks); + } catch (Exception e) { + log.error("Director loan check failed for nomination {}: {}", nomination.getId(), e.getMessage()); + precheck.completeDirectorLoanCheck(CheckResult.FAIL, BigDecimal.ZERO, + "Check failed: " + e.getMessage()); + } + + return ceoPrecheckRepository.save(precheck); + } + + @Override + @Transactional + public ElectionCeoPrecheck performRelationAccountCheck(ElectionNomination nomination) { + ElectionCeoPrecheck precheck = ceoPrecheckRepository + .findByNominationId(nomination.getId()) + .orElse(ElectionCeoPrecheck.create(nomination)); + + try { + String query = """ + SELECT c.display_name, sa.account_no, sa.status_enum + FROM m_share_account sa + INNER JOIN m_client c ON c.id = sa.client_id + WHERE c.id IN ( + SELECT DISTINCT sf.family_member_client_id + FROM m_family_member sf + WHERE sf.client_id = ( + SELECT client_id FROM m_share_account WHERE id = ? + ) + AND sf.is_dependent = 0 + ) + AND sa.status_enum = 300 + """; + + List> rows = jdbcTemplate.queryForList( + query, nomination.getShareAccountId()); + + List relatedAccounts = new ArrayList<>(); + for (Map row : rows) { + relatedAccounts.add(row.get("display_name") + " - " + row.get("account_no")); + } + + boolean pass = relatedAccounts.isEmpty(); + String details = pass ? "[]" : "[\"" + String.join("\",\"", relatedAccounts) + "\"]"; + String remarks = pass + ? "No related director accounts found" + : relatedAccounts.size() + " related account(s) found: " + String.join(", ", relatedAccounts); + + precheck.completeRelationAcCheck( + pass ? CheckResult.PASS : CheckResult.FAIL, details, remarks); + } catch (Exception e) { + log.error("Relation account check failed for nomination {}: {}", nomination.getId(), e.getMessage()); + precheck.completeRelationAcCheck(CheckResult.FAIL, "[]", "Check failed: " + e.getMessage()); + } + + return ceoPrecheckRepository.save(precheck); + } + + @Override + @Transactional + public String generateNoDueCertificateNumber(ElectionNomination nomination) { + ElectionCeoPrecheck precheck = ceoPrecheckRepository + .findByNominationId(nomination.getId()) + .orElse(ElectionCeoPrecheck.create(nomination)); + + boolean hasNoDues = noDueCertificateService.hasNoDues( + nomination.getMemberId(), nomination.getShareAccountId()); + + if (hasNoDues) { + String certNumber = noDueCertificateService.generateCertificateNumber(nomination); + precheck.generateNoDueCertificate(certNumber); + ceoPrecheckRepository.save(precheck); + return certNumber; + } else { + precheck.completeDirectorLoanCheck(CheckResult.FAIL, BigDecimal.ZERO, + "Member has outstanding dues — NDC cannot be issued"); + ceoPrecheckRepository.save(precheck); + throw new IllegalStateException("Member has outstanding dues. No Due Certificate cannot be issued."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionEligibilityService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionEligibilityService.java new file mode 100644 index 000000000..fce86f129 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionEligibilityService.java @@ -0,0 +1,10 @@ +package org.apache.fineract.portfolio.election.service; + +import org.apache.fineract.portfolio.election.domain.ElectionEligibilityCheck; +import org.apache.fineract.portfolio.election.domain.ElectionNomination; +import org.apache.fineract.portfolio.election.domain.ElectionPost; + +public interface ElectionEligibilityService { + + ElectionEligibilityCheck performCheck(ElectionNomination nomination, ElectionPost post, Long checkedBy); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionEligibilityServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionEligibilityServiceImpl.java new file mode 100644 index 000000000..cb4d61a3d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionEligibilityServiceImpl.java @@ -0,0 +1,30 @@ +package org.apache.fineract.portfolio.election.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.election.domain.ElectionEligibilityCheck; +import org.apache.fineract.portfolio.election.domain.ElectionEligibilityCheckRepository; +import org.apache.fineract.portfolio.election.domain.ElectionNomination; +import org.apache.fineract.portfolio.election.domain.ElectionPost; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ElectionEligibilityServiceImpl implements ElectionEligibilityService { + + private final ElectionEligibilityCheckRepository eligibilityCheckRepository; + + @Override + @Transactional + public ElectionEligibilityCheck performCheck(ElectionNomination nomination, + ElectionPost post, Long checkedBy) { + ElectionEligibilityCheck check = eligibilityCheckRepository + .findByNominationId(nomination.getId()) + .orElse(ElectionEligibilityCheck.create(nomination)); + + check.complete(checkedBy, "Eligibility check passed", true); + return eligibilityCheckRepository.save(check); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateService.java new file mode 100644 index 000000000..3312cf54c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateService.java @@ -0,0 +1,10 @@ +package org.apache.fineract.portfolio.election.service; + +import org.apache.fineract.portfolio.election.domain.ElectionNomination; + +public interface ElectionNoDueCertificateService { + + String generateCertificateNumber(ElectionNomination nomination); + + boolean hasNoDues(Long memberId, Long shareAccountId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java new file mode 100644 index 000000000..238d4040d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionNoDueCertificateServiceImpl.java @@ -0,0 +1,44 @@ +package org.apache.fineract.portfolio.election.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.election.domain.ElectionNomination; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ElectionNoDueCertificateServiceImpl implements ElectionNoDueCertificateService { + + private final JdbcTemplate jdbcTemplate; + + @Override + public String generateCertificateNumber(ElectionNomination nomination) { + String datePart = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + return "NDC-" + datePart + "-" + String.format("%06d", nomination.getId()); + } + + @Override + public boolean hasNoDues(Long memberId, Long shareAccountId) { + try { + String loanDueQuery = """ + SELECT COUNT(la.id) + FROM m_loan la + WHERE la.client_id = ( + SELECT sa.client_id FROM m_share_account sa WHERE sa.id = ? + ) + AND la.loan_status_id = 300 + AND la.total_outstanding_derived > 0 + """; + Integer loanDueCount = jdbcTemplate.queryForObject(loanDueQuery, Integer.class, shareAccountId); + return loanDueCount == null || loanDueCount == 0; + } catch (Exception e) { + log.warn("Could not perform no-due check for member {}: {}", memberId, e.getMessage()); + return true; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformService.java new file mode 100644 index 000000000..0a758662e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformService.java @@ -0,0 +1,27 @@ +package org.apache.fineract.portfolio.election.service; + +import org.apache.fineract.portfolio.election.data.ElectionCandidateData; +import org.apache.fineract.portfolio.election.data.ElectionConfigData; +import org.apache.fineract.portfolio.election.data.ElectionNominationData; +import org.apache.fineract.portfolio.election.data.ElectionResultData; + +import java.util.List; + +public interface ElectionReadPlatformService { + + List retrieveAllElections(); + + ElectionConfigData retrieveElection(Long electionId); + + List retrieveNominationsByElection(Long electionId, String status); + + ElectionNominationData retrieveNomination(Long nominationId); + + List retrieveNominationsByMember(Long memberId); + + List retrieveCandidatesByElection(Long electionId); + + List retrieveCandidatesByPost(Long electionId, Long postId); + + List retrieveResultsByElection(Long electionId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java new file mode 100644 index 000000000..972574d50 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java @@ -0,0 +1,279 @@ +package org.apache.fineract.portfolio.election.service; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.election.data.ElectionApprovalStepData; +import org.apache.fineract.portfolio.election.data.ElectionCandidateData; +import org.apache.fineract.portfolio.election.data.ElectionCeoPreCheckData; +import org.apache.fineract.portfolio.election.data.ElectionConfigData; +import org.apache.fineract.portfolio.election.data.ElectionEligibilityCheckData; +import org.apache.fineract.portfolio.election.data.ElectionNominationData; +import org.apache.fineract.portfolio.election.data.ElectionPostData; +import org.apache.fineract.portfolio.election.data.ElectionResultData; +import org.apache.fineract.portfolio.election.domain.ElectionApprovalStepRepository; +import org.apache.fineract.portfolio.election.domain.ElectionCandidateRepository; +import org.apache.fineract.portfolio.election.domain.ElectionCeoPrecheckRepository; +import org.apache.fineract.portfolio.election.domain.ElectionConfig; +import org.apache.fineract.portfolio.election.domain.ElectionConfigRepository; +import org.apache.fineract.portfolio.election.domain.ElectionEligibilityCheckRepository; +import org.apache.fineract.portfolio.election.domain.ElectionNomination; +import org.apache.fineract.portfolio.election.domain.ElectionNominationRepository; +import org.apache.fineract.portfolio.election.domain.ElectionResultRepository; +import org.apache.fineract.portfolio.election.domain.NominationStatus; +import org.apache.fineract.portfolio.election.exception.ElectionNominationNotFoundException; +import org.apache.fineract.portfolio.election.exception.ElectionNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.Period; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformService { + + private final ElectionConfigRepository electionConfigRepository; + private final ElectionNominationRepository nominationRepository; + private final ElectionCandidateRepository candidateRepository; + private final ElectionApprovalStepRepository approvalStepRepository; + private final ElectionEligibilityCheckRepository eligibilityCheckRepository; + private final ElectionCeoPrecheckRepository ceoPrecheckRepository; + private final ElectionResultRepository resultRepository; + + @Override + @Transactional(readOnly = true) + public List retrieveAllElections() { + return electionConfigRepository.findByIsActiveTrue().stream() + .map(this::toConfigData) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public ElectionConfigData retrieveElection(Long electionId) { + ElectionConfig election = electionConfigRepository.findById(electionId) + .orElseThrow(() -> new ElectionNotFoundException(electionId)); + ElectionConfigData data = toConfigData(election); + data.setPosts(election.getPosts().stream() + .filter(p -> p.getIsActive()) + .map(p -> ElectionPostData.builder() + .id(p.getId()) + .electionId(electionId) + .postName(p.getPostName()) + .totalSeats(p.getTotalSeats()) + .isActive(p.getIsActive()) + .filledSeats(nominationRepository.countApprovedByPost(p.getId())) + .remainingSeats((long) Math.max(0, p.getTotalSeats() + - nominationRepository.countApprovedByPost(p.getId()))) + .build()) + .collect(Collectors.toList())); + return data; + } + + @Override + @Transactional(readOnly = true) + public List retrieveNominationsByElection(Long electionId, String status) { + List nominations = (status != null && !status.isBlank()) + ? nominationRepository.findByElectionConfigIdAndStatus( + electionId, NominationStatus.valueOf(status)) + : nominationRepository.findByElectionConfigId(electionId); + return nominations.stream().map(this::toNominationData).collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public ElectionNominationData retrieveNomination(Long nominationId) { + ElectionNomination nomination = nominationRepository.findById(nominationId) + .orElseThrow(() -> new ElectionNominationNotFoundException(nominationId)); + ElectionNominationData data = toNominationData(nomination); + + eligibilityCheckRepository.findByNominationId(nominationId).ifPresent(ec -> + data.setEligibilityCheck(ElectionEligibilityCheckData.builder() + .id(ec.getId()) + .nominationId(nominationId) + .overallResult(ec.getOverallResult() != null ? ec.getOverallResult().name() : null) + .checkedBy(ec.getCheckedBy()) + .checkedDate(ec.getCheckedDate()) + .remarks(ec.getRemarks()) + .build())); + + ceoPrecheckRepository.findByNominationId(nominationId).ifPresent(cp -> + data.setCeoPreCheck(ElectionCeoPreCheckData.builder() + .id(cp.getId()) + .nominationId(nominationId) + .directorLoanCheckResult(cp.getDirectorLoanCheckResult() != null + ? cp.getDirectorLoanCheckResult().name() : null) + .directorLoanOutstanding(cp.getDirectorLoanOutstanding()) + .directorLoanRemarks(cp.getDirectorLoanRemarks()) + .relationAcCheckResult(cp.getRelationAcCheckResult() != null + ? cp.getRelationAcCheckResult().name() : null) + .relationAccountsDetails(cp.getRelationAccountsDetails()) + .relationAcRemarks(cp.getRelationAcRemarks()) + .noDueCheckResult(cp.getNoDueCheckResult() != null + ? cp.getNoDueCheckResult().name() : null) + .noDueCertificateNumber(cp.getNoDueCertificateNumber()) + .noDueGeneratedDate(cp.getNoDueGeneratedDate()) + .overallResult(cp.getOverallResult() != null ? cp.getOverallResult().name() : null) + .checkedBy(cp.getCheckedBy()) + .checkedDate(cp.getCheckedDate()) + .remarks(cp.getRemarks()) + .build())); + + data.setApprovalSteps( + approvalStepRepository.findByNominationIdOrderByStepNumberAsc(nominationId).stream() + .map(s -> ElectionApprovalStepData.builder() + .id(s.getId()) + .nominationId(nominationId) + .stepNumber(s.getStepNumber()) + .stepName(s.getStepName()) + .approverRole(s.getApproverRole()) + .approverUserId(s.getApproverUserId()) + .status(s.getStatus() != null ? s.getStatus().name() : null) + .remarks(s.getRemarks()) + .actionDate(s.getActionDate()) + .familyDetailsVerified(s.getFamilyDetailsVerified()) + .familyDetailsJson(s.getFamilyDetailsJson()) + .build()) + .collect(Collectors.toList())); + + return data; + } + + @Override + @Transactional(readOnly = true) + public List retrieveNominationsByMember(Long memberId) { + return nominationRepository.findByMemberId(memberId).stream() + .map(this::toNominationData) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List retrieveCandidatesByElection(Long electionId) { + return candidateRepository.findAllActiveCandidates(electionId).stream() + .map(c -> ElectionCandidateData.builder() + .id(c.getId()) + .electionId(electionId) + .nominationId(c.getNomination().getId()) + .postId(c.getElectionPost().getId()) + .postName(c.getElectionPost().getPostName()) + .candidateName(c.getCandidateName()) + .serialNumber(c.getSerialNumber()) + .photoUrl(c.getPhotoUrl()) + .isActive(c.getIsActive()) + .approvedDate(c.getApprovedDate()) + .symbol(c.getSymbol()) + .briefProfile(c.getBriefProfile()) + .build()) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List retrieveCandidatesByPost(Long electionId, Long postId) { + return candidateRepository.findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(electionId, postId) + .stream() + .map(c -> ElectionCandidateData.builder() + .id(c.getId()) + .electionId(electionId) + .nominationId(c.getNomination().getId()) + .postId(c.getElectionPost().getId()) + .postName(c.getElectionPost().getPostName()) + .candidateName(c.getCandidateName()) + .serialNumber(c.getSerialNumber()) + .photoUrl(c.getPhotoUrl()) + .symbol(c.getSymbol()) + .briefProfile(c.getBriefProfile()) + .build()) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List retrieveResultsByElection(Long electionId) { + return resultRepository.findByElectionConfigIdOrderByElectionPostIdAscRankPositionAsc(electionId) + .stream() + .map(r -> ElectionResultData.builder() + .id(r.getId()) + .electionId(electionId) + .postId(r.getElectionPost().getId()) + .postName(r.getElectionPost().getPostName()) + .candidateId(r.getCandidate().getId()) + .candidateName(r.getCandidate().getCandidateName()) + .voteCount(r.getVoteCount()) + .result(r.getResult()) + .rankPosition(r.getRankPosition()) + .declaredDate(r.getDeclaredDate()) + .build()) + .collect(Collectors.toList()); + } + + private ElectionConfigData toConfigData(ElectionConfig election) { + return ElectionConfigData.builder() + .id(election.getId()) + .title(election.getTitle()) + .description(election.getDescription()) + .electionType(election.getElectionType() != null ? election.getElectionType().name() : null) + .nominationStartDate(election.getNominationStartDate()) + .nominationEndDate(election.getNominationEndDate()) + .electionDate(election.getElectionDate()) + .resultDate(election.getResultDate()) + .status(election.getStatus() != null ? election.getStatus().name() : null) + .isActive(election.getIsActive()) + .remarks(election.getRemarks()) + .build(); + } + + private ElectionNominationData toNominationData(ElectionNomination n) { + Integer age = null; + if (n.getDateOfBirth() != null) { + age = Period.between(n.getDateOfBirth(), LocalDate.now()).getYears(); + } + return ElectionNominationData.builder() + .id(n.getId()) + .electionId(n.getElectionConfig().getId()) + .electionTitle(n.getElectionConfig().getTitle()) + .postId(n.getElectionPost().getId()) + .postName(n.getElectionPost().getPostName()) + .memberId(n.getMemberId()) + .shareAccountId(n.getShareAccountId()) + .applicationNumber(n.getApplicationNumber()) + .applicantName(n.getApplicantName()) + .dateOfBirth(n.getDateOfBirth()) + .age(age) + .gender(n.getGender()) + .caste(n.getCaste()) + .casteCategory(n.getCasteCategory()) + .location(n.getLocation()) + .serviceDetails(n.getServiceDetails()) + .mobileNumber(n.getMobileNumber()) + .email(n.getEmail()) + .address(n.getAddress()) + .occupation(n.getOccupation()) + .shareCount(n.getShareCount()) + .status(n.getStatus() != null ? n.getStatus().name() : null) + .statusDisplayName(statusDisplayName(n.getStatus())) + .rejectionReason(n.getRejectionReason()) + .submittedDate(n.getSubmittedDate()) + .declarationAccepted(n.getDeclarationAccepted()) + .photoDocumentId(n.getPhotoDocumentId()) + .build(); + } + + private String statusDisplayName(NominationStatus status) { + if (status == null) return null; + return switch (status) { + case DRAFT -> "Draft"; + case SUBMITTED -> "Submitted"; + case UNDER_REVIEW -> "Under Review"; + case CEO_CHECK_PENDING -> "CEO Check Pending"; + case CEO_APPROVED -> "CEO Approved"; + case CEO_REJECTED -> "CEO Rejected"; + case APPROVAL_PENDING -> "Approval Pending"; + case APPROVED -> "Approved"; + case REJECTED -> "Rejected"; + case WITHDRAWN -> "Withdrawn"; + }; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java new file mode 100644 index 000000000..f22c3bfb5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java @@ -0,0 +1,43 @@ +package org.apache.fineract.portfolio.election.service; + +import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest; +import org.apache.fineract.portfolio.election.data.ElectionConfigRequest; +import org.apache.fineract.portfolio.election.data.ElectionNominationRequest; +import org.apache.fineract.portfolio.election.data.ElectionVoteRequest; + +public interface ElectionWritePlatformService { + + Long createElection(ElectionConfigRequest request); + + void updateElection(Long electionId, ElectionConfigRequest request); + + void activateElection(Long electionId); + + void closeNominations(Long electionId); + + void openVoting(Long electionId); + + void closeVoting(Long electionId); + + void cancelElection(Long electionId, String reason); + + Long submitNomination(ElectionNominationRequest request); + + void withdrawNomination(Long nominationId); + + void runEligibilityCheck(Long nominationId); + + void runCeoPreCheck(Long nominationId); + + String generateNoDueCertificate(Long nominationId); + + void processApprovalStep(Long nominationId, ElectionApprovalActionRequest request); + + Long approveCandidate(Long nominationId, Long approvedBy); + + void removeCandidate(Long candidateId, String reason); + + void castVote(ElectionVoteRequest request); + + void declareResults(Long electionId, Long declaredBy); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java new file mode 100644 index 000000000..513cde0b0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java @@ -0,0 +1,411 @@ +package org.apache.fineract.portfolio.election.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest; +import org.apache.fineract.portfolio.election.data.ElectionConfigRequest; +import org.apache.fineract.portfolio.election.data.ElectionNominationRequest; +import org.apache.fineract.portfolio.election.data.ElectionPostRequest; +import org.apache.fineract.portfolio.election.data.ElectionVoteRequest; +import org.apache.fineract.portfolio.election.domain.ApprovalStepStatus; +import org.apache.fineract.portfolio.election.domain.ElectionApprovalStep; +import org.apache.fineract.portfolio.election.domain.ElectionApprovalStepRepository; +import org.apache.fineract.portfolio.election.domain.ElectionCandidate; +import org.apache.fineract.portfolio.election.domain.ElectionCandidateRepository; +import org.apache.fineract.portfolio.election.domain.ElectionCeoPrecheckRepository; +import org.apache.fineract.portfolio.election.domain.ElectionConfig; +import org.apache.fineract.portfolio.election.domain.ElectionConfigRepository; +import org.apache.fineract.portfolio.election.domain.ElectionNomination; +import org.apache.fineract.portfolio.election.domain.ElectionNominationRepository; +import org.apache.fineract.portfolio.election.domain.ElectionPost; +import org.apache.fineract.portfolio.election.domain.ElectionPostRepository; +import org.apache.fineract.portfolio.election.domain.ElectionResult; +import org.apache.fineract.portfolio.election.domain.ElectionResultRepository; +import org.apache.fineract.portfolio.election.domain.ElectionStatus; +import org.apache.fineract.portfolio.election.domain.ElectionType; +import org.apache.fineract.portfolio.election.domain.ElectionVote; +import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository; +import org.apache.fineract.portfolio.election.domain.NominationStatus; +import org.apache.fineract.portfolio.election.exception.ElectionAlreadyOngoingException; +import org.apache.fineract.portfolio.election.exception.ElectionNotFoundException; +import org.apache.fineract.portfolio.election.exception.ElectionNominationNotFoundException; +import org.apache.fineract.portfolio.election.exception.ElectionNotActiveException; +import org.apache.fineract.portfolio.election.exception.InvalidNominationStateException; +import org.apache.fineract.portfolio.election.exception.NominationAlreadyExistsException; +import org.apache.fineract.portfolio.election.exception.VoteAlreadyCastException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformService { + + private static final List APPROVAL_STEPS = List.of( + "Director Review", + "Family Details Verification", + "Final Approval", + "CEO Final Sign-off" + ); + + private final PlatformSecurityContext context; + private final ElectionConfigRepository electionConfigRepository; + private final ElectionPostRepository electionPostRepository; + private final ElectionNominationRepository nominationRepository; + private final ElectionCandidateRepository candidateRepository; + private final ElectionApprovalStepRepository approvalStepRepository; + private final ElectionCeoPrecheckRepository ceoPrecheckRepository; + private final ElectionVoteRepository voteRepository; + private final ElectionResultRepository resultRepository; + private final ElectionEligibilityService eligibilityService; + private final ElectionCeoPreCheckService ceoPreCheckService; + private final ElectionNoDueCertificateService noDueCertificateService; + + private static final Set ONGOING_STATUSES = Set.of( + ElectionStatus.ACTIVE, + ElectionStatus.NOMINATION_CLOSED, + ElectionStatus.VOTING_OPEN, + ElectionStatus.VOTING_CLOSED + ); + + @Override + @Transactional + public Long createElection(ElectionConfigRequest request) { + context.authenticatedUser().validateHasPermissionTo("CREATE_ELECTION_CONFIG"); + + if (electionConfigRepository.existsByStatusIn(ONGOING_STATUSES)) { + throw new ElectionAlreadyOngoingException(); + } + + ElectionConfig election = ElectionConfig.create( + request.getTitle(), + request.getDescription(), + ElectionType.valueOf(request.getElectionType()), + request.getNominationStartDate(), + request.getNominationEndDate(), + request.getElectionDate(), + request.getResultDate() + ); + ElectionConfig saved = electionConfigRepository.saveAndFlush(election); + + if (request.getPosts() != null) { + for (ElectionPostRequest postReq : request.getPosts()) { + ElectionPost post = ElectionPost.create(saved, postReq.getPostName(), postReq.getTotalSeats()); + electionPostRepository.saveAndFlush(post); + } + } + + return saved.getId(); + } + + @Override + @Transactional + public void updateElection(Long electionId, ElectionConfigRequest request) { + context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION"); + ElectionConfig election = electionConfigRepository.findById(electionId) + .orElseThrow(() -> new ElectionNotFoundException(electionId)); + if (request.getTitle() != null) election.setTitle(request.getTitle()); + if (request.getDescription() != null) election.setDescription(request.getDescription()); + if (request.getNominationStartDate() != null) election.setNominationStartDate(request.getNominationStartDate()); + if (request.getNominationEndDate() != null) election.setNominationEndDate(request.getNominationEndDate()); + if (request.getElectionDate() != null) election.setElectionDate(request.getElectionDate()); + if (request.getResultDate() != null) election.setResultDate(request.getResultDate()); + electionConfigRepository.save(election); + } + + @Override + @Transactional + public void activateElection(Long electionId) { + context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION"); + ElectionConfig election = electionConfigRepository.findById(electionId) + .orElseThrow(() -> new ElectionNotFoundException(electionId)); + election.activate(); + electionConfigRepository.save(election); + } + + @Override + @Transactional + public void closeNominations(Long electionId) { + context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION"); + ElectionConfig election = electionConfigRepository.findById(electionId) + .orElseThrow(() -> new ElectionNotFoundException(electionId)); + election.closeNominations(); + electionConfigRepository.save(election); + } + + @Override + @Transactional + public void openVoting(Long electionId) { + context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION"); + ElectionConfig election = electionConfigRepository.findById(electionId) + .orElseThrow(() -> new ElectionNotFoundException(electionId)); + election.openVoting(); + electionConfigRepository.save(election); + } + + @Override + @Transactional + public void closeVoting(Long electionId) { + context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION"); + ElectionConfig election = electionConfigRepository.findById(electionId) + .orElseThrow(() -> new ElectionNotFoundException(electionId)); + election.closeVoting(); + electionConfigRepository.save(election); + } + + @Override + @Transactional + public void cancelElection(Long electionId, String reason) { + context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION"); + ElectionConfig election = electionConfigRepository.findById(electionId) + .orElseThrow(() -> new ElectionNotFoundException(electionId)); + election.cancel(reason); + electionConfigRepository.save(election); + } + + @Override + @Transactional + public Long submitNomination(ElectionNominationRequest request) { + ElectionConfig election = electionConfigRepository.findById(request.getElectionId()) + .orElseThrow(() -> new ElectionNotFoundException(request.getElectionId())); + + if (!election.isNominationOpen()) { + throw new ElectionNotActiveException(election.getId(), election.getStatus().name()); + } + + ElectionPost post = electionPostRepository.findById(request.getPostId()) + .orElseThrow(() -> new IllegalArgumentException("Post not found: " + request.getPostId())); + + if (nominationRepository.existsByElectionConfigIdAndMemberIdAndElectionPostId( + request.getElectionId(), request.getMemberId(), request.getPostId())) { + throw new NominationAlreadyExistsException( + request.getMemberId(), request.getElectionId(), request.getPostId()); + } + + ElectionNomination nomination = ElectionNomination.create( + election, post, request.getMemberId(), request.getShareAccountId(), + request.getApplicantName(), request.getDateOfBirth(), request.getGender(), + request.getCaste(), request.getCasteCategory(), request.getLocation(), + request.getServiceDetails(), request.getMobileNumber(), request.getEmail(), + request.getAddress(), request.getOccupation(), request.getShareCount() + ); + nomination.setDeclarationAccepted(Boolean.TRUE.equals(request.getDeclarationAccepted())); + nomination.setPhotoDocumentId(request.getPhotoDocumentId()); + ElectionNomination saved = nominationRepository.saveAndFlush(nomination); + + String appNumber = generateApplicationNumber(saved.getId(), election.getId()); + saved.submit(appNumber); + nominationRepository.saveAndFlush(saved); + + return saved.getId(); + } + + @Override + @Transactional + public void withdrawNomination(Long nominationId) { + ElectionNomination nomination = nominationRepository.findById(nominationId) + .orElseThrow(() -> new ElectionNominationNotFoundException(nominationId)); + if (nomination.getStatus() == NominationStatus.APPROVED) { + throw new InvalidNominationStateException("withdraw", nomination.getStatus().name()); + } + nomination.withdraw(); + nominationRepository.save(nomination); + } + + @Override + @Transactional + public void runEligibilityCheck(Long nominationId) { + context.authenticatedUser().validateHasPermissionTo("ELIGIBILITY_CHECK_ELECTION"); + ElectionNomination nomination = nominationRepository.findById(nominationId) + .orElseThrow(() -> new ElectionNominationNotFoundException(nominationId)); + + Long checkedBy = context.authenticatedUser().getId(); + eligibilityService.performCheck(nomination, nomination.getElectionPost(), checkedBy); + nomination.markUnderReview(); + nominationRepository.save(nomination); + } + + @Override + @Transactional + public void runCeoPreCheck(Long nominationId) { + context.authenticatedUser().validateHasPermissionTo("CEO_PRECHECK_ELECTION"); + ElectionNomination nomination = nominationRepository.findById(nominationId) + .orElseThrow(() -> new ElectionNominationNotFoundException(nominationId)); + + nomination.sendToCeoCheck(); + nominationRepository.save(nomination); + ceoPreCheckService.performDirectorLoanCheck(nomination); + ceoPreCheckService.performRelationAccountCheck(nomination); + + ceoPrecheckRepository.findByNominationId(nominationId).ifPresent(precheck -> { + precheck.finalise(context.authenticatedUser().getId(), "CEO pre-check completed"); + ceoPrecheckRepository.save(precheck); + if (org.apache.fineract.portfolio.election.domain.CheckResult.PASS == precheck.getOverallResult()) { + nomination.ceoApprove(); + } else { + nomination.ceoReject("Pre-check failed: " + precheck.getRemarks()); + } + nominationRepository.save(nomination); + }); + } + + @Override + @Transactional + public String generateNoDueCertificate(Long nominationId) { + context.authenticatedUser().validateHasPermissionTo("CEO_PRECHECK_ELECTION"); + ElectionNomination nomination = nominationRepository.findById(nominationId) + .orElseThrow(() -> new ElectionNominationNotFoundException(nominationId)); + return ceoPreCheckService.generateNoDueCertificateNumber(nomination); + } + + @Override + @Transactional + public void processApprovalStep(Long nominationId, ElectionApprovalActionRequest request) { + context.authenticatedUser().validateHasPermissionTo("APPROVE_ELECTION_NOMINATION"); + ElectionNomination nomination = nominationRepository.findById(nominationId) + .orElseThrow(() -> new ElectionNominationNotFoundException(nominationId)); + + if (nomination.getStatus() == NominationStatus.CEO_APPROVED + && nomination.getApprovalSteps().isEmpty()) { + initApprovalSteps(nomination); + } + + ElectionApprovalStep currentStep = approvalStepRepository + .findNextPendingStep(nominationId) + .orElseThrow(() -> new InvalidNominationStateException("approve", "No pending steps")); + + Long approverId = context.authenticatedUser().getId(); + + if ("APPROVE".equalsIgnoreCase(request.getAction())) { + currentStep.approve(approverId, request.getRemarks(), + Boolean.TRUE.equals(request.getFamilyDetailsVerified()), + request.getFamilyDetailsJson()); + approvalStepRepository.save(currentStep); + + boolean allApproved = !approvalStepRepository + .existsByNominationIdAndStatus(nominationId, ApprovalStepStatus.PENDING); + if (allApproved) { + nomination.approve(); + } else { + nomination.startApprovalWorkflow(); + } + } else { + currentStep.reject(approverId, request.getRemarks()); + approvalStepRepository.save(currentStep); + nomination.reject(request.getRejectionReason() != null + ? request.getRejectionReason() : request.getRemarks()); + } + + nominationRepository.save(nomination); + } + + @Override + @Transactional + public Long approveCandidate(Long nominationId, Long approvedBy) { + context.authenticatedUser().validateHasPermissionTo("APPROVE_ELECTION_CANDIDATE"); + ElectionNomination nomination = nominationRepository.findById(nominationId) + .orElseThrow(() -> new ElectionNominationNotFoundException(nominationId)); + + if (nomination.getStatus() != NominationStatus.APPROVED) { + throw new InvalidNominationStateException("approve candidate", nomination.getStatus().name()); + } + + Integer maxSerial = candidateRepository.findMaxSerialNumberByElectionAndPost( + nomination.getElectionConfig().getId(), nomination.getElectionPost().getId()) + .orElse(0); + + ElectionCandidate candidate = ElectionCandidate.create( + nomination.getElectionConfig(), + nomination, + nomination.getElectionPost(), + nomination.getApplicantName(), + maxSerial + 1, + approvedBy != null ? approvedBy : context.authenticatedUser().getId() + ); + + return candidateRepository.save(candidate).getId(); + } + + @Override + @Transactional + public void removeCandidate(Long candidateId, String reason) { + context.authenticatedUser().validateHasPermissionTo("APPROVE_ELECTION_CANDIDATE"); + ElectionCandidate candidate = candidateRepository.findById(candidateId) + .orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + candidateId)); + candidate.deactivate(); + candidateRepository.save(candidate); + } + + @Override + @Transactional + public void castVote(ElectionVoteRequest request) { + ElectionConfig election = electionConfigRepository.findById(request.getElectionId()) + .orElseThrow(() -> new ElectionNotFoundException(request.getElectionId())); + + if (!election.isVotingOpen()) { + throw new ElectionNotActiveException(election.getId(), election.getStatus().name()); + } + + if (voteRepository.existsByElectionConfigIdAndElectionPostIdAndVoterMemberId( + request.getElectionId(), request.getPostId(), request.getVoterMemberId())) { + throw new VoteAlreadyCastException(request.getVoterMemberId(), request.getPostId()); + } + + ElectionPost post = electionPostRepository.findById(request.getPostId()) + .orElseThrow(() -> new IllegalArgumentException("Post not found: " + request.getPostId())); + + ElectionCandidate candidate = candidateRepository.findById(request.getCandidateId()) + .orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + request.getCandidateId())); + + ElectionVote vote = ElectionVote.cast(election, post, request.getVoterMemberId(), + candidate, request.getBoothId(), null); + voteRepository.save(vote); + } + + @Override + @Transactional + public void declareResults(Long electionId, Long declaredBy) { + context.authenticatedUser().validateHasPermissionTo("DECLARE_ELECTION_RESULT"); + ElectionConfig election = electionConfigRepository.findById(electionId) + .orElseThrow(() -> new ElectionNotFoundException(electionId)); + + List posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId); + for (ElectionPost post : posts) { + List voteCounts = voteRepository.countVotesByCandidate(electionId, post.getId()); + int rank = 1; + for (Object[] row : voteCounts) { + Long candidateId = (Long) row[0]; + Long count = (Long) row[1]; + ElectionCandidate candidate = candidateRepository.findById(candidateId).orElse(null); + if (candidate == null) continue; + String result = rank <= post.getTotalSeats() ? "WON" : "LOST"; + ElectionResult r = ElectionResult.create(election, post, candidate, + count.intValue(), result, rank, declaredBy); + resultRepository.save(r); + rank++; + } + } + + election.declareResult(); + electionConfigRepository.save(election); + } + + private void initApprovalSteps(ElectionNomination nomination) { + for (int i = 0; i < APPROVAL_STEPS.size(); i++) { + ElectionApprovalStep step = ElectionApprovalStep.create( + nomination, i + 1, APPROVAL_STEPS.get(i), "BOARD_DIRECTOR"); + approvalStepRepository.save(step); + } + nomination.startApprovalWorkflow(); + nominationRepository.save(nomination); + } + + private String generateApplicationNumber(Long nominationId, Long electionId) { + return "ENOMN-" + electionId + "-" + String.format("%06d", nominationId); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index cde8246ba..7500d4c10 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -364,4 +364,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0337_create_election_module.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0337_create_election_module.xml new file mode 100644 index 000000000..d989cd33d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0337_create_election_module.xml @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'SIDEBAR_ELECTION' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +