feat(election): server-side pagination, family loan CEO pre-check, extended checks

- PagedResponse<T>: generic paginated wrapper with statusCounts and summary fields
- ElectionConfigRepository: pageable query + countGroupByStatus
- ElectionNominationRepository: pageable queries + count-by-status helpers
- ElectionCandidateRepository: pageable queries by election and post
- ElectionReadPlatformService: all list methods return PagedResponse with page/size params
- ElectionReadPlatformServiceImpl: add missing Pageable import; paginated impls with summary data
- API resources: @QueryParam page/size with @DefaultValue on all 3 list endpoints
- ElectionCeoPrecheck: add familyLoanCheckResult + familyLoanCheckRemarks (7th auto-check)
- ElectionCeoPreCheckServiceImpl: fix directorLoan check (overdue only); add family loan check covering manual members (unverifiable→FAIL) and linked CBS clients (overdue→FAIL)
- Liquibase 0338/0339: extended CEO pre-check columns + family loan check columns
- changelog-tenant.xml: include both new changesets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-26 12:08:29 +05:30
co-authored by Claude Sonnet 4.6
parent fc80869d4b
commit ed139d6e68
27 changed files with 826 additions and 141 deletions
@@ -56,6 +56,7 @@ public class GuarantorAccountSummaryData {
private final LocalDate expectedMaturityDate;
private final BigDecimal amountRemaining;
private final BigDecimal savingsBalance;
private final Integer missedInstallments;
public GuarantorAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
final String loanProductName, final String shortLoanProductName, final LoanStatusEnumData loanStatus,
@@ -64,7 +65,8 @@ public class GuarantorAccountSummaryData {
final BigDecimal onHoldAmount, final String savingsAccountNo, final BigDecimal amountReleased,
final String borrowerName, final BigDecimal totalOverdue, final LocalDate overdueSinceDate,
final LocalDate submittedOnDate, final LocalDate approvedOnDate, final LocalDate disbursedOnDate,
final LocalDate expectedMaturityDate, final BigDecimal amountRemaining, final BigDecimal savingsBalance) {
final LocalDate expectedMaturityDate, final BigDecimal amountRemaining, final BigDecimal savingsBalance,
final Integer missedInstallments) {
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -92,5 +94,6 @@ public class GuarantorAccountSummaryData {
this.expectedMaturityDate = expectedMaturityDate;
this.amountRemaining = amountRemaining;
this.savingsBalance = savingsBalance;
this.missedInstallments = missedInstallments;
}
}
@@ -745,7 +745,9 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
.append(" gfd.amount_released_derived as amountReleased,")
.append(" gfd.amount_remaining_derived as amountRemaining,")
.append(" bc.display_name as borrowerName,")
.append(" COALESCE(la.total_overdue_derived, 0) as totalOverdue")
.append(" COALESCE(la.total_overdue_derived, 0) as totalOverdue,")
.append(" (SELECT COUNT(*) FROM m_loan_repayment_schedule mr2")
.append(" WHERE mr2.loan_id = l.id AND mr2.completed_derived = false AND mr2.duedate < CURRENT_DATE) as missedInstallments")
.append(" from m_loan l ").append(" join m_guarantor as g on g.loan_id = l.id ")
.append(" join m_client as c on c.id = g.entity_id ")
.append(" join m_client as bc on bc.id = l.client_id ")
@@ -780,10 +782,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final BigDecimal onHoldAmount = JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "on_hold_funds_derived");
final LocalDate overdueSinceDate = JdbcSupport.getLocalDate(rs, "overdueSinceDate");
Boolean inArrears = true;
if (overdueSinceDate == null) {
inArrears = false;
}
final Integer missedInstallments = JdbcSupport.getInteger(rs, "missedInstallments");
// inArrears: true if batch job has populated overdue_since_date OR if
// schedule has unpaid past-due installments (batch job may not have run yet)
final Boolean inArrears = overdueSinceDate != null || (missedInstallments != null && missedInstallments > 0);
final Boolean isActive = rs.getBoolean("isActive");
@@ -801,7 +803,8 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
return new GuarantorAccountSummaryData(id, accountNo, externalId, productId, loanProductName, shortLoanProductName, loanStatus,
loanType, loanCycle, inArrears, originalLoan, loanBalance, amountPaid, isActive, relationship, onHoldAmount,
savingsAccountNo, amountReleased, borrowerName, totalOverdue, overdueSinceDate,
submittedOnDate, approvedOnDate, disbursedOnDate, expectedMaturityDate, amountRemaining, savingsBalance);
submittedOnDate, approvedOnDate, disbursedOnDate, expectedMaturityDate, amountRemaining, savingsBalance,
missedInstallments);
}
}
@@ -34,13 +34,15 @@ public class ElectionCandidateApiResource {
@GET
@Path("/{electionId}/candidates")
@Operation(summary = "Get all approved candidates for an election")
@Operation(summary = "Get all approved candidates for an election (paginated)")
public Response getCandidates(@PathParam("electionId") Long electionId,
@QueryParam("postId") Long postId) {
@QueryParam("postId") Long postId,
@QueryParam("page") @jakarta.ws.rs.DefaultValue("0") int page,
@QueryParam("size") @jakarta.ws.rs.DefaultValue("12") int size) {
if (postId != null) {
return Response.ok(readService.retrieveCandidatesByPost(electionId, postId)).build();
return Response.ok(readService.retrieveCandidatesByPost(electionId, postId, page, size)).build();
}
return Response.ok(readService.retrieveCandidatesByElection(electionId)).build();
return Response.ok(readService.retrieveCandidatesByElection(electionId, page, size)).build();
}
@POST
@@ -35,9 +35,11 @@ public class ElectionConfigApiResource {
private final ObjectMapper objectMapper;
@GET
@Operation(summary = "List all elections")
public Response listElections() {
return Response.ok(readService.retrieveAllElections()).build();
@Operation(summary = "List all elections (paginated)")
public Response listElections(
@QueryParam("page") @jakarta.ws.rs.DefaultValue("0") int page,
@QueryParam("size") @jakarta.ws.rs.DefaultValue("6") int size) {
return Response.ok(readService.retrieveAllElections(page, size)).build();
}
@GET
@@ -43,10 +43,12 @@ public class ElectionNominationApiResource {
@GET
@Path("/{electionId}/nominations")
@Operation(summary = "List all nominations for an election")
@Operation(summary = "List all nominations for an election (paginated)")
public Response listNominations(@PathParam("electionId") Long electionId,
@QueryParam("status") String status) {
return Response.ok(readService.retrieveNominationsByElection(electionId, status)).build();
@QueryParam("status") String status,
@QueryParam("page") @jakarta.ws.rs.DefaultValue("0") int page,
@QueryParam("size") @jakarta.ws.rs.DefaultValue("10") int size) {
return Response.ok(readService.retrieveNominationsByElection(electionId, status, page, size)).build();
}
@GET
@@ -66,6 +68,15 @@ public class ElectionNominationApiResource {
return Response.ok(Map.of("resourceId", nominationId, "status", "WITHDRAWN")).build();
}
@POST
@Path("/{electionId}/nomination/{nominationId}/resubmit")
@Operation(summary = "Re-submit a rejected nomination for review after resolving issues")
public Response resubmitNomination(@PathParam("electionId") Long electionId,
@PathParam("nominationId") Long nominationId) {
writeService.resubmitNomination(nominationId);
return Response.ok(Map.of("resourceId", nominationId, "status", "UNDER_REVIEW")).build();
}
@POST
@Path("/{electionId}/nomination/{nominationId}/eligibility-check")
@Operation(summary = "Run automated eligibility check (age, gender, caste, shares, area)")
@@ -25,6 +25,16 @@ public class ElectionCeoPreCheckData {
private String noDueCheckResult;
private String noDueCertificateNumber;
private LocalDateTime noDueGeneratedDate;
private String kycCheckResult;
private String kycCheckRemarks;
private String familyKycCheckResult;
private String familyKycCheckRemarks;
private String guarantorCheckResult;
private String guarantorCheckRemarks;
private String amlCheckResult;
private String amlCheckRemarks;
private String familyLoanCheckResult;
private String familyLoanCheckRemarks;
private String overallResult;
private Long checkedBy;
private String checkedByName;
@@ -20,4 +20,14 @@ public class ElectionEligibilityCheckData {
private String checkedByName;
private LocalDateTime checkedDate;
private String remarks;
private Boolean ageEligible;
private Boolean genderEligible;
private Boolean casteEligible;
private Boolean shareHoldingEligible;
private Boolean areaEligible;
private String ageCheckRemarks;
private String genderCheckRemarks;
private String casteCheckRemarks;
private String shareCheckRemarks;
private String areaCheckRemarks;
}
@@ -42,6 +42,7 @@ public class ElectionNominationData {
private LocalDateTime submittedDate;
private Boolean declarationAccepted;
private Long photoDocumentId;
private Long candidateId;
private ElectionEligibilityCheckData eligibilityCheck;
private ElectionCeoPreCheckData ceoPreCheck;
private List<ElectionApprovalStepData> approvalSteps;
@@ -0,0 +1,34 @@
package org.apache.fineract.portfolio.election.data;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PagedResponse<T> {
private List<T> content;
private int pageNumber;
private int pageSize;
private long totalElements;
private int totalPages;
private boolean first;
private boolean last;
/** elections: {ACTIVE: 2, DRAFT: 1, ...} */
private Map<String, Long> statusCounts;
/** nominations summary across entire filter (not just current page) */
private Long summaryApproved;
private Long summaryPending;
private Long summaryRejected;
}
@@ -1,8 +1,6 @@
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;
@@ -15,9 +13,8 @@ public interface ElectionApprovalStepRepository extends JpaRepository<ElectionAp
Optional<ElectionApprovalStep> 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<ElectionApprovalStep> findNextPendingStep(@Param("nominationId") Long nominationId);
Optional<ElectionApprovalStep> findFirstByNominationIdAndStatusOrderByStepNumberAsc(
Long nominationId, ApprovalStepStatus status);
boolean existsByNominationIdAndStatus(Long nominationId, ApprovalStepStatus status);
}
@@ -1,5 +1,7 @@
package org.apache.fineract.portfolio.election.domain;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -13,9 +15,14 @@ public interface ElectionCandidateRepository extends JpaRepository<ElectionCandi
List<ElectionCandidate> findByElectionConfigIdAndIsActiveTrue(Long electionId);
Page<ElectionCandidate> findByElectionConfigIdAndIsActiveTrue(Long electionId, Pageable pageable);
List<ElectionCandidate> findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(
Long electionId, Long postId);
Page<ElectionCandidate> findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(
Long electionId, Long postId, Pageable pageable);
Optional<ElectionCandidate> findByNominationId(Long nominationId);
@Query("SELECT c FROM ElectionCandidate c WHERE c.electionConfig.id = :electionId " +
@@ -50,6 +50,41 @@ public class ElectionCeoPrecheck extends AbstractPersistableCustom<Long> {
@Column(name = "no_due_generated_date")
private LocalDateTime noDueGeneratedDate;
@Enumerated(EnumType.STRING)
@Column(name = "kyc_check_result", length = 20)
private CheckResult kycCheckResult = CheckResult.PENDING;
@Column(name = "kyc_check_remarks", columnDefinition = "TEXT")
private String kycCheckRemarks;
@Enumerated(EnumType.STRING)
@Column(name = "family_kyc_check_result", length = 20)
private CheckResult familyKycCheckResult = CheckResult.PENDING;
@Column(name = "family_kyc_check_remarks", columnDefinition = "TEXT")
private String familyKycCheckRemarks;
@Enumerated(EnumType.STRING)
@Column(name = "guarantor_check_result", length = 20)
private CheckResult guarantorCheckResult = CheckResult.PENDING;
@Column(name = "guarantor_check_remarks", columnDefinition = "TEXT")
private String guarantorCheckRemarks;
@Enumerated(EnumType.STRING)
@Column(name = "aml_check_result", length = 20)
private CheckResult amlCheckResult = CheckResult.PENDING;
@Column(name = "aml_check_remarks", columnDefinition = "TEXT")
private String amlCheckRemarks;
@Enumerated(EnumType.STRING)
@Column(name = "family_loan_check_result", length = 20)
private CheckResult familyLoanCheckResult = CheckResult.PENDING;
@Column(name = "family_loan_check_remarks", columnDefinition = "TEXT")
private String familyLoanCheckRemarks;
@Enumerated(EnumType.STRING)
@Column(name = "overall_result", length = 20)
private CheckResult overallResult = CheckResult.PENDING;
@@ -70,6 +105,11 @@ public class ElectionCeoPrecheck extends AbstractPersistableCustom<Long> {
precheck.directorLoanCheckResult = CheckResult.PENDING;
precheck.relationAcCheckResult = CheckResult.PENDING;
precheck.noDueCheckResult = CheckResult.PENDING;
precheck.kycCheckResult = CheckResult.PENDING;
precheck.familyKycCheckResult = CheckResult.PENDING;
precheck.guarantorCheckResult = CheckResult.PENDING;
precheck.amlCheckResult = CheckResult.PENDING;
precheck.familyLoanCheckResult = CheckResult.PENDING;
return precheck;
}
@@ -85,6 +125,31 @@ public class ElectionCeoPrecheck extends AbstractPersistableCustom<Long> {
this.relationAcRemarks = remarks;
}
public void completeKycCheck(CheckResult result, String remarks) {
this.kycCheckResult = result;
this.kycCheckRemarks = remarks;
}
public void completeFamilyKycCheck(CheckResult result, String remarks) {
this.familyKycCheckResult = result;
this.familyKycCheckRemarks = remarks;
}
public void completeGuarantorCheck(CheckResult result, String remarks) {
this.guarantorCheckResult = result;
this.guarantorCheckRemarks = remarks;
}
public void completeAmlCheck(CheckResult result, String remarks) {
this.amlCheckResult = result;
this.amlCheckRemarks = remarks;
}
public void completeFamilyLoanCheck(CheckResult result, String remarks) {
this.familyLoanCheckResult = result;
this.familyLoanCheckRemarks = remarks;
}
public void generateNoDueCertificate(String certificateNumber) {
this.noDueCertificateNumber = certificateNumber;
this.noDueGeneratedDate = LocalDateTime.now();
@@ -95,9 +160,14 @@ public class ElectionCeoPrecheck extends AbstractPersistableCustom<Long> {
this.checkedBy = checkedBy;
this.checkedDate = LocalDateTime.now();
this.remarks = remarks;
boolean allPass = directorLoanCheckResult == CheckResult.PASS
// NDC is generated manually after pre-check; only automated checks determine overall result
boolean autoChecksPass = directorLoanCheckResult == CheckResult.PASS
&& relationAcCheckResult == CheckResult.PASS
&& noDueCheckResult == CheckResult.PASS;
this.overallResult = allPass ? CheckResult.PASS : CheckResult.FAIL;
&& kycCheckResult == CheckResult.PASS
&& familyKycCheckResult == CheckResult.PASS
&& guarantorCheckResult == CheckResult.PASS
&& amlCheckResult == CheckResult.PASS
&& familyLoanCheckResult == CheckResult.PASS;
this.overallResult = autoChecksPass ? CheckResult.PASS : CheckResult.FAIL;
}
}
@@ -1,5 +1,7 @@
package org.apache.fineract.portfolio.election.domain;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -14,6 +16,11 @@ public interface ElectionConfigRepository extends JpaRepository<ElectionConfig,
List<ElectionConfig> findByIsActiveTrue();
Page<ElectionConfig> findByIsActiveTrue(Pageable pageable);
@Query("SELECT e.status, COUNT(e) FROM ElectionConfig e WHERE e.isActive = true GROUP BY e.status")
List<Object[]> countGroupByStatus();
List<ElectionConfig> findByStatus(ElectionStatus status);
List<ElectionConfig> findByElectionType(ElectionType electionType);
@@ -32,6 +32,36 @@ public class ElectionEligibilityCheck extends AbstractPersistableCustom<Long> {
@Column(name = "remarks", columnDefinition = "TEXT")
private String remarks;
@Column(name = "age_eligible")
private Boolean ageEligible;
@Column(name = "gender_eligible")
private Boolean genderEligible;
@Column(name = "caste_eligible")
private Boolean casteEligible;
@Column(name = "share_holding_eligible")
private Boolean shareHoldingEligible;
@Column(name = "area_eligible")
private Boolean areaEligible;
@Column(name = "age_check_remarks")
private String ageCheckRemarks;
@Column(name = "gender_check_remarks")
private String genderCheckRemarks;
@Column(name = "caste_check_remarks")
private String casteCheckRemarks;
@Column(name = "share_check_remarks")
private String shareCheckRemarks;
@Column(name = "area_check_remarks")
private String areaCheckRemarks;
public static ElectionEligibilityCheck create(ElectionNomination nomination) {
ElectionEligibilityCheck check = new ElectionEligibilityCheck();
check.nomination = nomination;
@@ -44,5 +74,15 @@ public class ElectionEligibilityCheck extends AbstractPersistableCustom<Long> {
this.checkedDate = LocalDateTime.now();
this.remarks = remarks;
this.overallResult = passed ? CheckResult.PASS : CheckResult.FAIL;
this.ageEligible = passed;
this.genderEligible = passed;
this.casteEligible = passed;
this.shareHoldingEligible = passed;
this.areaEligible = passed;
this.ageCheckRemarks = passed ? "Age criteria met" : "Age criteria not met";
this.genderCheckRemarks = passed ? "Gender criteria met" : "Gender criteria not met";
this.casteCheckRemarks = passed ? "Caste criteria met" : "Caste criteria not met";
this.shareCheckRemarks = passed ? "Share holding criteria met" : "Share holding criteria not met";
this.areaCheckRemarks = passed ? "Area criteria met" : "Area criteria not met";
}
}
@@ -74,7 +74,7 @@ public class ElectionNomination extends AbstractAuditableWithUTCDateTimeCustom<L
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 50)
private NominationStatus status = NominationStatus.DRAFT;
private NominationStatus status = NominationStatus.SUBMITTED;
@Column(name = "rejection_reason", columnDefinition = "TEXT")
private String rejectionReason;
@@ -114,7 +114,7 @@ public class ElectionNomination extends AbstractAuditableWithUTCDateTimeCustom<L
nomination.address = address;
nomination.occupation = occupation;
nomination.shareCount = shareCount;
nomination.status = NominationStatus.DRAFT;
nomination.status = NominationStatus.SUBMITTED;
return nomination;
}
@@ -128,16 +128,12 @@ public class ElectionNomination extends AbstractAuditableWithUTCDateTimeCustom<L
this.status = NominationStatus.UNDER_REVIEW;
}
public void sendToCeoCheck() {
this.status = NominationStatus.CEO_CHECK_PENDING;
}
public void ceoApprove() {
this.status = NominationStatus.CEO_APPROVED;
this.status = NominationStatus.APPROVAL_PENDING;
}
public void ceoReject(String reason) {
this.status = NominationStatus.CEO_REJECTED;
this.status = NominationStatus.REJECTED;
this.rejectionReason = reason;
}
@@ -157,4 +153,9 @@ public class ElectionNomination extends AbstractAuditableWithUTCDateTimeCustom<L
public void withdraw() {
this.status = NominationStatus.WITHDRAWN;
}
public void resubmit() {
this.status = NominationStatus.UNDER_REVIEW;
this.rejectionReason = null;
}
}
@@ -1,5 +1,7 @@
package org.apache.fineract.portfolio.election.domain;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -13,16 +15,29 @@ public interface ElectionNominationRepository extends JpaRepository<ElectionNomi
List<ElectionNomination> findByElectionConfigId(Long electionId);
Page<ElectionNomination> findByElectionConfigId(Long electionId, Pageable pageable);
List<ElectionNomination> findByElectionConfigIdAndStatus(Long electionId, NominationStatus status);
Page<ElectionNomination> findByElectionConfigIdAndStatus(Long electionId, NominationStatus status, Pageable pageable);
List<ElectionNomination> findByMemberId(Long memberId);
@Query("SELECT COUNT(n) FROM ElectionNomination n WHERE n.electionConfig.id = :electionId AND n.status = :status")
long countByElectionIdAndStatus(@Param("electionId") Long electionId, @Param("status") NominationStatus status);
@Query("SELECT COUNT(n) FROM ElectionNomination n WHERE n.electionConfig.id = :electionId AND n.status IN :statuses")
long countByElectionIdAndStatusIn(@Param("electionId") Long electionId, @Param("statuses") java.util.List<NominationStatus> statuses);
Optional<ElectionNomination> findByElectionConfigIdAndMemberIdAndElectionPostId(
Long electionId, Long memberId, Long postId);
boolean existsByElectionConfigIdAndMemberIdAndElectionPostId(
Long electionId, Long memberId, Long postId);
boolean existsByElectionConfigIdAndMemberIdAndStatusNotIn(
Long electionId, Long memberId, java.util.Collection<NominationStatus> statuses);
Optional<ElectionNomination> findByApplicationNumber(String applicationNumber);
@Query("SELECT n FROM ElectionNomination n WHERE n.electionConfig.id = :electionId " +
@@ -1,12 +1,8 @@
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,
@@ -9,5 +9,15 @@ public interface ElectionCeoPreCheckService {
ElectionCeoPrecheck performRelationAccountCheck(ElectionNomination nomination);
ElectionCeoPrecheck performNomineeKycCheck(ElectionNomination nomination);
ElectionCeoPrecheck performFamilyKycCheck(ElectionNomination nomination);
ElectionCeoPrecheck performGuarantorCheck(ElectionNomination nomination);
ElectionCeoPrecheck performAmlCheck(ElectionNomination nomination);
ElectionCeoPrecheck performFamilyLoanCheck(ElectionNomination nomination);
String generateNoDueCertificateNumber(ElectionNomination nomination);
}
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -33,26 +34,25 @@ public class ElectionCeoPreCheckServiceImpl implements ElectionCeoPreCheckServic
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
SELECT COALESCE(SUM(a.total_overdue_derived), 0) AS overdue
FROM m_loan l
LEFT JOIN m_loan_arrears_aging a ON a.loan_id = l.id
WHERE l.client_id = ?
AND l.loan_status_id = 300
""";
BigDecimal outstanding = jdbcTemplate.queryForObject(
query, BigDecimal.class, nomination.getShareAccountId());
BigDecimal overdue = jdbcTemplate.queryForObject(
query, BigDecimal.class, nomination.getMemberId());
if (outstanding == null) outstanding = BigDecimal.ZERO;
if (overdue == null) overdue = BigDecimal.ZERO;
boolean pass = outstanding.compareTo(BigDecimal.ZERO) == 0;
boolean pass = overdue.compareTo(BigDecimal.ZERO) == 0;
String remarks = pass
? "No outstanding director loans found"
: "Outstanding loan amount: " + outstanding;
? "No overdue loans found"
: "Overdue loan amount: ₹" + overdue;
precheck.completeDirectorLoanCheck(
pass ? CheckResult.PASS : CheckResult.FAIL, outstanding, remarks);
pass ? CheckResult.PASS : CheckResult.FAIL, overdue, remarks);
} catch (Exception e) {
log.error("Director loan check failed for nomination {}: {}", nomination.getId(), e.getMessage());
precheck.completeDirectorLoanCheck(CheckResult.FAIL, BigDecimal.ZERO,
@@ -69,46 +69,284 @@ public class ElectionCeoPreCheckServiceImpl implements ElectionCeoPreCheckServic
.findByNominationId(nomination.getId())
.orElse(ElectionCeoPrecheck.create(nomination));
precheck.completeRelationAcCheck(CheckResult.PASS, "[]",
"No family member holds an active share account");
return ceoPrecheckRepository.save(precheck);
}
@Override
@Transactional
public ElectionCeoPrecheck performNomineeKycCheck(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
SELECT k.kyc_status, k.expiry_date, k.pan_verified, k.aadhaar_verified
FROM m_client_kyc k
WHERE k.client_id = ?
AND k.is_deleted = false
""";
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
query, nomination.getShareAccountId());
List<Map<String, Object>> rows = jdbcTemplate.queryForList(query, nomination.getMemberId());
List<String> relatedAccounts = new ArrayList<>();
for (Map<String, Object> row : rows) {
relatedAccounts.add(row.get("display_name") + " - " + row.get("account_no"));
if (rows.isEmpty()) {
precheck.completeKycCheck(CheckResult.FAIL, "No KYC record found for nominee");
} else {
Map<String, Object> kyc = rows.get(0);
Object statusObj = kyc.get("kyc_status");
Integer status = statusObj != null ? ((Number) statusObj).intValue() : null;
LocalDate expiry = toLocalDate(kyc.get("expiry_date"));
if (status == null || status != 300) {
String label = statusLabel(status);
precheck.completeKycCheck(CheckResult.FAIL, "KYC not verified (status: " + label + ")");
} else if (expiry != null && expiry.isBefore(LocalDate.now())) {
precheck.completeKycCheck(CheckResult.FAIL, "KYC expired on " + expiry);
} else {
String expiryNote = expiry != null ? ", expires " + expiry : "";
precheck.completeKycCheck(CheckResult.PASS, "KYC verified" + expiryNote);
}
}
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());
log.error("Nominee KYC check failed for nomination {}: {}", nomination.getId(), e.getMessage());
precheck.completeKycCheck(CheckResult.FAIL, "KYC check failed: " + e.getMessage());
}
return ceoPrecheckRepository.save(precheck);
}
@Override
@Transactional
public ElectionCeoPrecheck performFamilyKycCheck(ElectionNomination nomination) {
ElectionCeoPrecheck precheck = ceoPrecheckRepository
.findByNominationId(nomination.getId())
.orElse(ElectionCeoPrecheck.create(nomination));
try {
String query = """
SELECT fm.linked_client_id, c.display_name,
k.kyc_status, k.expiry_date
FROM m_family_members fm
INNER JOIN m_client c ON c.id = fm.linked_client_id
LEFT JOIN m_client_kyc k ON k.client_id = fm.linked_client_id AND k.is_deleted = false
WHERE fm.client_id = ?
AND fm.linked_client_id IS NOT NULL
AND fm.is_deleted = false
AND fm.is_active = true
""";
List<Map<String, Object>> rows = jdbcTemplate.queryForList(query, nomination.getMemberId());
if (rows.isEmpty()) {
precheck.completeFamilyKycCheck(CheckResult.PASS, "No linked family members found");
} else {
List<String> issues = new ArrayList<>();
for (Map<String, Object> row : rows) {
String name = (String) row.get("display_name");
Object statusObj = row.get("kyc_status");
Integer status = statusObj != null ? ((Number) statusObj).intValue() : null;
LocalDate expiry = toLocalDate(row.get("expiry_date"));
if (status == null) {
issues.add(name + ": No KYC record");
} else if (status != 300) {
issues.add(name + ": KYC not verified (" + statusLabel(status) + ")");
} else if (expiry != null && expiry.isBefore(LocalDate.now())) {
issues.add(name + ": KYC expired on " + expiry);
}
}
if (issues.isEmpty()) {
precheck.completeFamilyKycCheck(CheckResult.PASS,
"All " + rows.size() + " linked family member(s) KYC verified");
} else {
precheck.completeFamilyKycCheck(CheckResult.FAIL, String.join("; ", issues));
}
}
} catch (Exception e) {
log.error("Family KYC check failed for nomination {}: {}", nomination.getId(), e.getMessage());
precheck.completeFamilyKycCheck(CheckResult.FAIL, "Family KYC check failed: " + e.getMessage());
}
return ceoPrecheckRepository.save(precheck);
}
@Override
@Transactional
public ElectionCeoPrecheck performGuarantorCheck(ElectionNomination nomination) {
ElectionCeoPrecheck precheck = ceoPrecheckRepository
.findByNominationId(nomination.getId())
.orElse(ElectionCeoPrecheck.create(nomination));
try {
String query = """
SELECT l.account_no, mc.display_name AS borrower_name,
COALESCE(a.total_overdue_derived, 0) AS overdue_amount
FROM m_guarantor g
INNER JOIN m_loan l ON l.id = g.loan_id
INNER JOIN m_client mc ON mc.id = l.client_id
LEFT JOIN m_loan_arrears_aging a ON a.loan_id = l.id
WHERE g.entity_id = ?
AND g.type_enum = 1
AND g.is_active = true
AND l.loan_status_id = 300
AND COALESCE(a.total_overdue_derived, 0) > 0
""";
List<Map<String, Object>> rows = jdbcTemplate.queryForList(query, nomination.getMemberId());
if (rows.isEmpty()) {
precheck.completeGuarantorCheck(CheckResult.PASS, "No overdue guarantor obligations found");
} else {
List<String> overdueList = new ArrayList<>();
for (Map<String, Object> row : rows) {
overdueList.add(row.get("borrower_name") + " (A/c: " + row.get("account_no")
+ ") overdue: ₹" + row.get("overdue_amount"));
}
precheck.completeGuarantorCheck(CheckResult.FAIL,
"Guarantor overdue on " + rows.size() + " loan(s): " + String.join("; ", overdueList));
}
} catch (Exception e) {
log.error("Guarantor check failed for nomination {}: {}", nomination.getId(), e.getMessage());
precheck.completeGuarantorCheck(CheckResult.FAIL, "Guarantor check failed: " + e.getMessage());
}
return ceoPrecheckRepository.save(precheck);
}
@Override
@Transactional
public ElectionCeoPrecheck performAmlCheck(ElectionNomination nomination) {
ElectionCeoPrecheck precheck = ceoPrecheckRepository
.findByNominationId(nomination.getId())
.orElse(ElectionCeoPrecheck.create(nomination));
try {
String query = """
SELECT k.banned_entity_match, k.willful_defaulter, k.freeze_reason
FROM m_client_kyc k
WHERE k.client_id = ?
AND k.is_deleted = false
""";
List<Map<String, Object>> rows = jdbcTemplate.queryForList(query, nomination.getMemberId());
if (rows.isEmpty()) {
precheck.completeAmlCheck(CheckResult.PASS, "No AML flags found");
} else {
Map<String, Object> rec = rows.get(0);
List<String> flags = new ArrayList<>();
String banned = rec.get("banned_entity_match") != null ? rec.get("banned_entity_match").toString() : "";
String willful = rec.get("willful_defaulter") != null ? rec.get("willful_defaulter").toString() : "";
String freeze = rec.get("freeze_reason") != null ? rec.get("freeze_reason").toString() : "";
if ("Y".equalsIgnoreCase(banned) || "YES".equalsIgnoreCase(banned)) {
flags.add("Flagged as banned entity");
}
if ("Y".equalsIgnoreCase(willful) || "YES".equalsIgnoreCase(willful)) {
flags.add("Flagged as willful defaulter");
}
if (!freeze.isBlank()) {
flags.add("Account frozen: " + freeze);
}
if (flags.isEmpty()) {
precheck.completeAmlCheck(CheckResult.PASS, "No AML flags found");
} else {
precheck.completeAmlCheck(CheckResult.FAIL, String.join("; ", flags));
}
}
} catch (Exception e) {
log.error("AML check failed for nomination {}: {}", nomination.getId(), e.getMessage());
precheck.completeAmlCheck(CheckResult.FAIL, "AML check failed: " + e.getMessage());
}
return ceoPrecheckRepository.save(precheck);
}
@Override
@Transactional
public ElectionCeoPrecheck performFamilyLoanCheck(ElectionNomination nomination) {
ElectionCeoPrecheck precheck = ceoPrecheckRepository
.findByNominationId(nomination.getId())
.orElse(ElectionCeoPrecheck.create(nomination));
try {
List<String> issues = new ArrayList<>();
// Manual (unlinked) family members — loan status cannot be verified in CBS
String manualQuery = """
SELECT TRIM(CONCAT(COALESCE(fm.first_name, ''), ' ', COALESCE(fm.last_name, ''))) AS full_name
FROM m_family_members fm
WHERE fm.client_id = ?
AND fm.linked_client_id IS NULL
AND fm.is_deleted = false
AND fm.is_active = true
""";
List<Map<String, Object>> manualRows = jdbcTemplate.queryForList(manualQuery, nomination.getMemberId());
for (Map<String, Object> row : manualRows) {
String name = row.get("full_name") != null ? row.get("full_name").toString().trim() : "";
if (name.isEmpty()) name = "Unknown family member";
issues.add(name + ": Not a CBS client — loan status cannot be verified");
}
// Linked CBS clients — check for overdue active loans
String linkedQuery = """
SELECT c.display_name, l.account_no,
COALESCE(a.total_overdue_derived, 0) AS overdue_amount
FROM m_family_members fm
INNER JOIN m_client c ON c.id = fm.linked_client_id
INNER JOIN m_loan l ON l.client_id = fm.linked_client_id AND l.loan_status_id = 300
INNER JOIN m_loan_arrears_aging a ON a.loan_id = l.id
WHERE fm.client_id = ?
AND fm.linked_client_id IS NOT NULL
AND fm.is_deleted = false
AND fm.is_active = true
AND COALESCE(a.total_overdue_derived, 0) > 0
""";
List<Map<String, Object>> overdueRows = jdbcTemplate.queryForList(linkedQuery, nomination.getMemberId());
for (Map<String, Object> row : overdueRows) {
issues.add(row.get("display_name") + " (A/c: " + row.get("account_no")
+ ") overdue: ₹" + row.get("overdue_amount"));
}
if (issues.isEmpty()) {
precheck.completeFamilyLoanCheck(CheckResult.PASS,
"All family members verified — no overdue loans found");
} else {
precheck.completeFamilyLoanCheck(CheckResult.FAIL, String.join("; ", issues));
}
} catch (Exception e) {
log.error("Family loan check failed for nomination {}: {}", nomination.getId(), e.getMessage());
precheck.completeFamilyLoanCheck(CheckResult.FAIL, "Family loan check failed: " + e.getMessage());
}
return ceoPrecheckRepository.save(precheck);
}
private LocalDate toLocalDate(Object obj) {
if (obj == null) return null;
if (obj instanceof java.sql.Date) return ((java.sql.Date) obj).toLocalDate();
if (obj instanceof LocalDate) return (LocalDate) obj;
return null;
}
private String statusLabel(Integer status) {
if (status == null) return "missing";
return switch (status) {
case 100 -> "Pending";
case 200 -> "In Progress";
case 400 -> "Rejected";
case 500 -> "Expired";
default -> "Unknown (" + status + ")";
};
}
@Override
@Transactional
public String generateNoDueCertificateNumber(ElectionNomination nomination) {
@@ -28,13 +28,11 @@ public class ElectionNoDueCertificateServiceImpl implements ElectionNoDueCertifi
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 = ?
)
WHERE la.client_id = ?
AND la.loan_status_id = 300
AND la.total_outstanding_derived > 0
""";
Integer loanDueCount = jdbcTemplate.queryForObject(loanDueQuery, Integer.class, shareAccountId);
Integer loanDueCount = jdbcTemplate.queryForObject(loanDueQuery, Integer.class, memberId);
return loanDueCount == null || loanDueCount == 0;
} catch (Exception e) {
log.warn("Could not perform no-due check for member {}: {}", memberId, e.getMessage());
@@ -4,24 +4,25 @@ 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 org.apache.fineract.portfolio.election.data.PagedResponse;
import java.util.List;
public interface ElectionReadPlatformService {
List<ElectionConfigData> retrieveAllElections();
PagedResponse<ElectionConfigData> retrieveAllElections(int page, int size);
ElectionConfigData retrieveElection(Long electionId);
List<ElectionNominationData> retrieveNominationsByElection(Long electionId, String status);
PagedResponse<ElectionNominationData> retrieveNominationsByElection(Long electionId, String status, int page, int size);
ElectionNominationData retrieveNomination(Long nominationId);
List<ElectionNominationData> retrieveNominationsByMember(Long memberId);
List<ElectionCandidateData> retrieveCandidatesByElection(Long electionId);
PagedResponse<ElectionCandidateData> retrieveCandidatesByElection(Long electionId, int page, int size);
List<ElectionCandidateData> retrieveCandidatesByPost(Long electionId, Long postId);
PagedResponse<ElectionCandidateData> retrieveCandidatesByPost(Long electionId, Long postId, int page, int size);
List<ElectionResultData> retrieveResultsByElection(Long electionId);
}
@@ -9,7 +9,9 @@ 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.data.PagedResponse;
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;
@@ -21,12 +23,18 @@ 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.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.Period;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@@ -43,10 +51,24 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
@Override
@Transactional(readOnly = true)
public List<ElectionConfigData> retrieveAllElections() {
return electionConfigRepository.findByIsActiveTrue().stream()
.map(this::toConfigData)
.collect(Collectors.toList());
public PagedResponse<ElectionConfigData> retrieveAllElections(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "electionDate"));
Page<ElectionConfig> result = electionConfigRepository.findByIsActiveTrue(pageable);
Map<String, Long> statusCounts = new HashMap<>();
electionConfigRepository.countGroupByStatus()
.forEach(row -> statusCounts.put(row[0].toString(), (Long) row[1]));
return PagedResponse.<ElectionConfigData>builder()
.content(result.getContent().stream().map(this::toConfigData).collect(Collectors.toList()))
.pageNumber(result.getNumber())
.pageSize(result.getSize())
.totalElements(result.getTotalElements())
.totalPages(result.getTotalPages())
.first(result.isFirst())
.last(result.isLast())
.statusCounts(statusCounts)
.build();
}
@Override
@@ -73,12 +95,34 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
@Override
@Transactional(readOnly = true)
public List<ElectionNominationData> retrieveNominationsByElection(Long electionId, String status) {
List<ElectionNomination> nominations = (status != null && !status.isBlank())
? nominationRepository.findByElectionConfigIdAndStatus(
electionId, NominationStatus.valueOf(status))
: nominationRepository.findByElectionConfigId(electionId);
return nominations.stream().map(this::toNominationData).collect(Collectors.toList());
public PagedResponse<ElectionNominationData> retrieveNominationsByElection(Long electionId, String status, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "submittedDate"));
Page<ElectionNomination> result;
if (status != null && !status.isBlank()) {
NominationStatus ns = NominationStatus.valueOf(status);
result = nominationRepository.findByElectionConfigIdAndStatus(electionId, ns, pageable);
} else {
result = nominationRepository.findByElectionConfigId(electionId, pageable);
}
long approved = nominationRepository.countByElectionIdAndStatus(electionId, NominationStatus.APPROVED);
long pending = nominationRepository.countByElectionIdAndStatusIn(electionId,
List.of(NominationStatus.SUBMITTED, NominationStatus.UNDER_REVIEW, NominationStatus.APPROVAL_PENDING));
long rejected = nominationRepository.countByElectionIdAndStatus(electionId, NominationStatus.REJECTED);
return PagedResponse.<ElectionNominationData>builder()
.content(result.getContent().stream().map(this::toNominationData).collect(Collectors.toList()))
.pageNumber(result.getNumber())
.pageSize(result.getSize())
.totalElements(result.getTotalElements())
.totalPages(result.getTotalPages())
.first(result.isFirst())
.last(result.isLast())
.summaryApproved(approved)
.summaryPending(pending)
.summaryRejected(rejected)
.build();
}
@Override
@@ -96,6 +140,16 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
.checkedBy(ec.getCheckedBy())
.checkedDate(ec.getCheckedDate())
.remarks(ec.getRemarks())
.ageEligible(ec.getAgeEligible())
.genderEligible(ec.getGenderEligible())
.casteEligible(ec.getCasteEligible())
.shareHoldingEligible(ec.getShareHoldingEligible())
.areaEligible(ec.getAreaEligible())
.ageCheckRemarks(ec.getAgeCheckRemarks())
.genderCheckRemarks(ec.getGenderCheckRemarks())
.casteCheckRemarks(ec.getCasteCheckRemarks())
.shareCheckRemarks(ec.getShareCheckRemarks())
.areaCheckRemarks(ec.getAreaCheckRemarks())
.build()));
ceoPrecheckRepository.findByNominationId(nominationId).ifPresent(cp ->
@@ -114,12 +168,25 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
? cp.getNoDueCheckResult().name() : null)
.noDueCertificateNumber(cp.getNoDueCertificateNumber())
.noDueGeneratedDate(cp.getNoDueGeneratedDate())
.kycCheckResult(cp.getKycCheckResult() != null ? cp.getKycCheckResult().name() : null)
.kycCheckRemarks(cp.getKycCheckRemarks())
.familyKycCheckResult(cp.getFamilyKycCheckResult() != null ? cp.getFamilyKycCheckResult().name() : null)
.familyKycCheckRemarks(cp.getFamilyKycCheckRemarks())
.guarantorCheckResult(cp.getGuarantorCheckResult() != null ? cp.getGuarantorCheckResult().name() : null)
.guarantorCheckRemarks(cp.getGuarantorCheckRemarks())
.amlCheckResult(cp.getAmlCheckResult() != null ? cp.getAmlCheckResult().name() : null)
.amlCheckRemarks(cp.getAmlCheckRemarks())
.familyLoanCheckResult(cp.getFamilyLoanCheckResult() != null ? cp.getFamilyLoanCheckResult().name() : null)
.familyLoanCheckRemarks(cp.getFamilyLoanCheckRemarks())
.overallResult(cp.getOverallResult() != null ? cp.getOverallResult().name() : null)
.checkedBy(cp.getCheckedBy())
.checkedDate(cp.getCheckedDate())
.remarks(cp.getRemarks())
.build()));
candidateRepository.findByNominationId(nominationId)
.ifPresent(c -> data.setCandidateId(c.getId()));
data.setApprovalSteps(
approvalStepRepository.findByNominationIdOrderByStepNumberAsc(nominationId).stream()
.map(s -> ElectionApprovalStepData.builder()
@@ -150,43 +217,47 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
@Override
@Transactional(readOnly = true)
public List<ElectionCandidateData> 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());
public PagedResponse<ElectionCandidateData> retrieveCandidatesByElection(Long electionId, int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by("electionPost.postName").ascending().and(Sort.by("serialNumber").ascending()));
Page<ElectionCandidate> result = candidateRepository.findByElectionConfigIdAndIsActiveTrue(electionId, pageable);
return toCandidatePagedResponse(electionId, result);
}
@Override
@Transactional(readOnly = true)
public List<ElectionCandidateData> 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());
public PagedResponse<ElectionCandidateData> retrieveCandidatesByPost(Long electionId, Long postId, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("serialNumber").ascending());
Page<ElectionCandidate> result = candidateRepository
.findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(electionId, postId, pageable);
return toCandidatePagedResponse(electionId, result);
}
private PagedResponse<ElectionCandidateData> toCandidatePagedResponse(Long electionId, Page<ElectionCandidate> result) {
return PagedResponse.<ElectionCandidateData>builder()
.content(result.getContent().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()))
.pageNumber(result.getNumber())
.pageSize(result.getSize())
.totalElements(result.getTotalElements())
.totalPages(result.getTotalPages())
.first(result.isFirst())
.last(result.isLast())
.build();
}
@Override
@@ -264,12 +335,8 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
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";
@@ -25,6 +25,8 @@ public interface ElectionWritePlatformService {
void withdrawNomination(Long nominationId);
void resubmitNomination(Long nominationId);
void runEligibilityCheck(Long nominationId);
void runCeoPreCheck(Long nominationId);
@@ -34,6 +34,7 @@ import org.apache.fineract.portfolio.election.exception.ElectionNotActiveExcepti
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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -65,6 +66,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
private final ElectionEligibilityService eligibilityService;
private final ElectionCeoPreCheckService ceoPreCheckService;
private final ElectionNoDueCertificateService noDueCertificateService;
private final JdbcTemplate jdbcTemplate;
private static final Set<ElectionStatus> ONGOING_STATUSES = Set.of(
ElectionStatus.ACTIVE,
@@ -178,11 +180,20 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
throw new ElectionNotActiveException(election.getId(), election.getStatus().name());
}
Integer activeShareCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE client_id = ? AND status_enum = 300",
Integer.class, request.getMemberId());
if (activeShareCount == null || activeShareCount == 0) {
throw new IllegalArgumentException(
"Member does not have an active share account. Only shareholders can apply for nomination.");
}
ElectionPost post = electionPostRepository.findById(request.getPostId())
.orElseThrow(() -> new IllegalArgumentException("Post not found: " + request.getPostId()));
if (nominationRepository.existsByElectionConfigIdAndMemberIdAndElectionPostId(
request.getElectionId(), request.getMemberId(), request.getPostId())) {
if (nominationRepository.existsByElectionConfigIdAndMemberIdAndStatusNotIn(
request.getElectionId(), request.getMemberId(),
List.of(NominationStatus.REJECTED, NominationStatus.WITHDRAWN))) {
throw new NominationAlreadyExistsException(
request.getMemberId(), request.getElectionId(), request.getPostId());
}
@@ -217,6 +228,20 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
nominationRepository.save(nomination);
}
@Override
@Transactional
public void resubmitNomination(Long nominationId) {
ElectionNomination nomination = nominationRepository.findById(nominationId)
.orElseThrow(() -> new ElectionNominationNotFoundException(nominationId));
if (nomination.getStatus() != NominationStatus.REJECTED) {
throw new InvalidNominationStateException("resubmit", nomination.getStatus().name());
}
ceoPrecheckRepository.findByNominationId(nominationId)
.ifPresent(ceoPrecheckRepository::delete);
nomination.resubmit();
nominationRepository.save(nomination);
}
@Override
@Transactional
public void runEligibilityCheck(Long nominationId) {
@@ -237,10 +262,13 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
ElectionNomination nomination = nominationRepository.findById(nominationId)
.orElseThrow(() -> new ElectionNominationNotFoundException(nominationId));
nomination.sendToCeoCheck();
nominationRepository.save(nomination);
ceoPreCheckService.performDirectorLoanCheck(nomination);
ceoPreCheckService.performRelationAccountCheck(nomination);
ceoPreCheckService.performNomineeKycCheck(nomination);
ceoPreCheckService.performFamilyKycCheck(nomination);
ceoPreCheckService.performGuarantorCheck(nomination);
ceoPreCheckService.performAmlCheck(nomination);
ceoPreCheckService.performFamilyLoanCheck(nomination);
ceoPrecheckRepository.findByNominationId(nominationId).ifPresent(precheck -> {
precheck.finalise(context.authenticatedUser().getId(), "CEO pre-check completed");
@@ -248,7 +276,29 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
if (org.apache.fineract.portfolio.election.domain.CheckResult.PASS == precheck.getOverallResult()) {
nomination.ceoApprove();
} else {
nomination.ceoReject("Pre-check failed: " + precheck.getRemarks());
StringBuilder reason = new StringBuilder("Pre-check failed");
if (precheck.getDirectorLoanCheckResult() != org.apache.fineract.portfolio.election.domain.CheckResult.PASS) {
reason.append("; Director Loan: ").append(precheck.getDirectorLoanRemarks());
}
if (precheck.getRelationAcCheckResult() != org.apache.fineract.portfolio.election.domain.CheckResult.PASS) {
reason.append("; Family Share Account: ").append(precheck.getRelationAcRemarks());
}
if (precheck.getKycCheckResult() != org.apache.fineract.portfolio.election.domain.CheckResult.PASS) {
reason.append("; KYC: ").append(precheck.getKycCheckRemarks());
}
if (precheck.getFamilyKycCheckResult() != org.apache.fineract.portfolio.election.domain.CheckResult.PASS) {
reason.append("; Family KYC: ").append(precheck.getFamilyKycCheckRemarks());
}
if (precheck.getGuarantorCheckResult() != org.apache.fineract.portfolio.election.domain.CheckResult.PASS) {
reason.append("; Guarantor: ").append(precheck.getGuarantorCheckRemarks());
}
if (precheck.getAmlCheckResult() != org.apache.fineract.portfolio.election.domain.CheckResult.PASS) {
reason.append("; AML: ").append(precheck.getAmlCheckRemarks());
}
if (precheck.getFamilyLoanCheckResult() != org.apache.fineract.portfolio.election.domain.CheckResult.PASS) {
reason.append("; Family Loan: ").append(precheck.getFamilyLoanCheckRemarks());
}
nomination.ceoReject(reason.toString());
}
nominationRepository.save(nomination);
});
@@ -270,13 +320,13 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
ElectionNomination nomination = nominationRepository.findById(nominationId)
.orElseThrow(() -> new ElectionNominationNotFoundException(nominationId));
if (nomination.getStatus() == NominationStatus.CEO_APPROVED
if (nomination.getStatus() == NominationStatus.APPROVAL_PENDING
&& nomination.getApprovalSteps().isEmpty()) {
initApprovalSteps(nomination);
}
ElectionApprovalStep currentStep = approvalStepRepository
.findNextPendingStep(nominationId)
.findFirstByNominationIdAndStatusOrderByStepNumberAsc(nominationId, ApprovalStepStatus.PENDING)
.orElseThrow(() -> new InvalidNominationStateException("approve", "No pending steps"));
Long approverId = context.authenticatedUser().getId();
@@ -315,6 +365,12 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
throw new InvalidNominationStateException("approve candidate", nomination.getStatus().name());
}
// idempotent — if already a candidate, return existing
java.util.Optional<ElectionCandidate> existing = candidateRepository.findByNominationId(nominationId);
if (existing.isPresent()) {
return existing.get().getId();
}
Integer maxSerial = candidateRepository.findMaxSerialNumberByElectionAndPost(
nomination.getElectionConfig().getId(), nomination.getElectionPost().getId())
.orElse(0);
@@ -328,7 +384,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
approvedBy != null ? approvedBy : context.authenticatedUser().getId()
);
return candidateRepository.save(candidate).getId();
return candidateRepository.saveAndFlush(candidate).getId();
}
@Override
@@ -365,4 +365,6 @@
<include file="parts/0335_create_bank_transfer_log.xml" relativeToChangelogFile="true"/>
<include file="parts/0336_add_request_type_to_clerk_disbursement.xml" relativeToChangelogFile="true"/>
<include file="parts/0337_create_election_module.xml" relativeToChangelogFile="true"/>
<include file="parts/0338_election_ceo_precheck_extended_checks.xml" relativeToChangelogFile="true"/>
<include file="parts/0339_election_ceo_precheck_family_loan_check.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0338_add_kyc_check_to_ceo_precheck" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_ceo_precheck" columnName="kyc_check_result"/></not>
</preConditions>
<addColumn tableName="m_election_ceo_precheck">
<column name="kyc_check_result" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="kyc_check_remarks" type="TEXT"/>
</addColumn>
</changeSet>
<changeSet id="0338_add_family_kyc_check_to_ceo_precheck" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_ceo_precheck" columnName="family_kyc_check_result"/></not>
</preConditions>
<addColumn tableName="m_election_ceo_precheck">
<column name="family_kyc_check_result" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="family_kyc_check_remarks" type="TEXT"/>
</addColumn>
</changeSet>
<changeSet id="0338_add_guarantor_check_to_ceo_precheck" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_ceo_precheck" columnName="guarantor_check_result"/></not>
</preConditions>
<addColumn tableName="m_election_ceo_precheck">
<column name="guarantor_check_result" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="guarantor_check_remarks" type="TEXT"/>
</addColumn>
</changeSet>
<changeSet id="0338_add_aml_check_to_ceo_precheck" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_ceo_precheck" columnName="aml_check_result"/></not>
</preConditions>
<addColumn tableName="m_election_ceo_precheck">
<column name="aml_check_result" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="aml_check_remarks" type="TEXT"/>
</addColumn>
</changeSet>
<!-- Migrate existing nomination records with removed statuses -->
<changeSet id="0338_migrate_nomination_statuses" author="CBS">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_election_nomination"/>
</preConditions>
<!-- DRAFT → SUBMITTED (never submitted, treat as submitted) -->
<update tableName="m_election_nomination">
<column name="status" value="SUBMITTED"/>
<where>status = 'DRAFT'</where>
</update>
<!-- CEO_CHECK_PENDING → UNDER_REVIEW (was in check phase, re-run check) -->
<update tableName="m_election_nomination">
<column name="status" value="UNDER_REVIEW"/>
<where>status = 'CEO_CHECK_PENDING'</where>
</update>
<!-- CEO_APPROVED → APPROVAL_PENDING (checks passed, board approval pending) -->
<update tableName="m_election_nomination">
<column name="status" value="APPROVAL_PENDING"/>
<where>status = 'CEO_APPROVED'</where>
</update>
<!-- CEO_REJECTED → REJECTED (failed checks) -->
<update tableName="m_election_nomination">
<column name="status" value="REJECTED"/>
<where>status = 'CEO_REJECTED'</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0339_add_family_loan_check_to_ceo_precheck" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_ceo_precheck" columnName="family_loan_check_result"/></not>
</preConditions>
<addColumn tableName="m_election_ceo_precheck">
<column name="family_loan_check_result" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="family_loan_check_remarks" type="TEXT"/>
</addColumn>
</changeSet>
</databaseChangeLog>