From 388af0a6a3e20706b9c38200f76d006718ce5eaa Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Tue, 30 Jun 2026 11:54:01 +0530 Subject: [PATCH] =?UTF-8?q?feat(election):=20candidate=20voter=20analysis?= =?UTF-8?q?=20API=20=E2=80=94=20multi-dimension=20breakdown=20+=20sub-cast?= =?UTF-8?q?e=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ElectionAnalyticsServiceImpl with 14 SQL breakdown queries: gender, age, area, social category (caste), sub-caste, religion, occupation, share-holding, tenure, loan, education, marital status, monthly income, savings account depth - Cross-dimension match: caste + area simultaneously (core voter base) - Zero-count row injection for caste/gender/sub-caste when candidate's category has no shareholder matches in GROUP BY result - Liquibase 0343: sub_caste_cv_id column on m_client with 33 pre-seeded sub-caste values across General / OBC / SC / ST categories - Liquibase 0342: election post eligibility columns - CandidateVoterAnalysisData DTO with education, marital, income, savings, activeSavingsHolders, crossMatchCount fields Co-Authored-By: Claude Sonnet 4.6 --- .../api/ElectionCandidateApiResource.java | 10 + .../data/CandidateVoterAnalysisData.java | 104 +++ .../service/ElectionAnalyticsService.java | 14 + .../service/ElectionAnalyticsServiceImpl.java | 614 ++++++++++++++++++ .../db/changelog/tenant/changelog-tenant.xml | 2 + ..._election_post_add_eligibility_columns.xml | 59 ++ .../parts/0343_client_sub_caste_column.xml | 138 ++++ 7 files changed, 941 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CandidateVoterAnalysisData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsServiceImpl.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_election_post_add_eligibility_columns.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0343_client_sub_caste_column.xml 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 index 1fd8f2ab6..9f4dfd982 100644 --- 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 @@ -19,6 +19,7 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.election.domain.ElectionAuditAction; +import org.apache.fineract.portfolio.election.service.ElectionAnalyticsService; import org.apache.fineract.portfolio.election.service.ElectionAuditService; import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService; import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService; @@ -45,6 +46,7 @@ public class ElectionCandidateApiResource { private final PlatformSecurityContext context; private final ContentStoreService contentStoreService; private final ElectionAuditService electionAuditService; + private final ElectionAnalyticsService analyticsService; @GET @Path("/{electionId}/candidates") @@ -168,6 +170,14 @@ public class ElectionCandidateApiResource { return Response.ok(Map.of("resourceId", candidateId, "status", "REMOVED")).build(); } + @GET + @Path("/{electionId}/candidates/{candidateId}/voter-analysis") + @Operation(summary = "Get demographic voter analysis for a candidate") + public Response getCandidateVoterAnalysis(@PathParam("electionId") Long electionId, + @PathParam("candidateId") Long candidateId) { + return Response.ok(analyticsService.getCandidateVoterAnalysis(candidateId)).build(); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CandidateVoterAnalysisData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CandidateVoterAnalysisData.java new file mode 100644 index 000000000..ba9028958 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CandidateVoterAnalysisData.java @@ -0,0 +1,104 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CandidateVoterAnalysisData { + + // ── Candidate / Post info ───────────────────────────────────────────────── + private Long candidateId; + private Long electionId; + private String candidateName; + private String postName; + private String candidateGender; + private String candidateCaste; + private String candidateLocation; // free-text from nomination form (informational only) + private String candidateArea; // city from m_client_address (used for area match) + private Integer candidateAge; + + // ── Post eligibility criteria ───────────────────────────────────────────── + private String postGenderType; + private Integer postMinAge; + private Integer postMaxAge; + private Integer postMinShareHolding; + + // ── Voter pool summary ──────────────────────────────────────────────────── + private long totalActiveShareholders; + + /** Shareholders who satisfy ALL post criteria simultaneously */ + private long actualEligibleVoters; + + // ── Gender breakdown ────────────────────────────────────────────────────── + private List genderBreakdown; + + // ── Age breakdown ───────────────────────────────────────────────────────── + private List ageBreakdown; + private long eligibleByAge; + + // ── Area / Office breakdown ─────────────────────────────────────────────── + private List areaBreakdown; + private long areaMatchCount; + + // ── Caste / Social Category breakdown ──────────────────────────────────── + private List casteBreakdown; + private long casteMatchCount; + + // ── Sub-caste breakdown ─────────────────────────────────────────────────── + private String candidateSubCaste; + private List subCasteBreakdown; + private long subCasteMatchCount; + + // ── Religion breakdown ──────────────────────────────────────────────────── + private List religionBreakdown; + + // ── Occupation breakdown ────────────────────────────────────────────────── + private List occupationBreakdown; + + // ── Share holding distribution ──────────────────────────────────────────── + private List shareHoldingBreakdown; + + // ── Member tenure breakdown ──────────────────────────────────────────────── + private List tenureBreakdown; + + // ── Loan engagement ─────────────────────────────────────────────────────── + private List loanHolderBreakdown; + private long activeLoanHolders; + + // ── Family members ──────────────────────────────────────────────────────── + private long shareholdersWithFamily; + private long totalFamilyMembers; + + // ── Education breakdown ─────────────────────────────────────────────────── + private List educationBreakdown; + + // ── Marital status breakdown ────────────────────────────────────────────── + private List maritalBreakdown; + + // ── Monthly income distribution ─────────────────────────────────────────── + private List monthlyIncomeBreakdown; + + // ── Savings account engagement ──────────────────────────────────────────── + private List savingsBreakdown; + private long activeSavingsHolders; + + // ── Cross-dimension match (caste + area) ────────────────────────────────── + private long crossMatchCount; + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class BreakdownItem { + private String label; + private long count; + private double percent; + private boolean match; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsService.java new file mode 100644 index 000000000..379fc66b4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsService.java @@ -0,0 +1,14 @@ +package org.apache.fineract.portfolio.election.service; + +import org.apache.fineract.portfolio.election.data.CandidateVoterAnalysisData; + +public interface ElectionAnalyticsService { + + /** + * Returns a voter-base demographic analysis for a given candidate. + * Compares all active shareholders against the candidate's own + * gender, caste, location and age — highlighting which segments + * are naturally aligned with the candidate's profile. + */ + CandidateVoterAnalysisData getCandidateVoterAnalysis(Long candidateId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsServiceImpl.java new file mode 100644 index 000000000..6b635fa4f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAnalyticsServiceImpl.java @@ -0,0 +1,614 @@ +package org.apache.fineract.portfolio.election.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.portfolio.election.data.CandidateVoterAnalysisData; +import org.apache.fineract.portfolio.election.data.CandidateVoterAnalysisData.BreakdownItem; +import jakarta.ws.rs.NotFoundException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.Period; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ElectionAnalyticsServiceImpl implements ElectionAnalyticsService { + + private final JdbcTemplate jdbcTemplate; + + // ── Candidate + post info ────────────────────────────────────────────────── + private static final String SQL_CANDIDATE_INFO = """ + SELECT ec.id, ec.election_id, + n.applicant_name, n.gender, n.caste_category, n.caste AS sub_caste, n.location, n.date_of_birth, + p.post_name, p.min_age, p.max_age, p.gender_type, p.area, p.min_share_holding, + COALESCE(NULLIF(ca.city,''), NULLIF(ca.town_village,''), NULLIF(ca.county_district,'')) AS candidate_city + FROM m_election_candidate ec + JOIN m_election_nomination n ON ec.nomination_id = n.id + JOIN m_election_post p ON ec.post_id = p.id + LEFT JOIN LATERAL ( + SELECT ma.city, ma.town_village, ma.county_district + FROM m_client_address mca + JOIN m_address ma ON ma.id = mca.address_id + WHERE mca.client_id = n.member_id + AND mca.is_active = true AND mca.is_deleted = false + ORDER BY mca.id DESC LIMIT 1 + ) ca ON true + WHERE ec.id = ? + """; + + // ── Active shareholder totals ────────────────────────────────────────────── + private static final String SQL_TOTAL = """ + SELECT COUNT(DISTINCT sa.client_id) + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id + WHERE sa.status_enum = 300 AND c.status_enum = 300 + """; + + // ── Gender ──────────────────────────────────────────────────────────────── + private static final String SQL_GENDER = """ + SELECT COALESCE(cv.code_value, 'Not Specified') AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_code_value cv ON c.gender_cv_id = cv.id + WHERE sa.status_enum = 300 + GROUP BY cv.code_value + ORDER BY cnt DESC + """; + + // ── Age bands ───────────────────────────────────────────────────────────── + private static final String SQL_AGE = """ + SELECT + CASE + WHEN c.date_of_birth IS NULL THEN 'Not Specified' + WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, c.date_of_birth)) < 25 THEN 'Under 25' + WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, c.date_of_birth)) < 35 THEN '25–34' + WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, c.date_of_birth)) < 45 THEN '35–44' + WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, c.date_of_birth)) < 55 THEN '45–54' + WHEN EXTRACT(YEAR FROM AGE(CURRENT_DATE, c.date_of_birth)) < 65 THEN '55–64' + ELSE '65+' + END AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + WHERE sa.status_enum = 300 + GROUP BY label + ORDER BY cnt DESC + """; + + // ── Area (city/locality from client address) ────────────────────────────── + private static final String SQL_AREA = """ + SELECT COALESCE(NULLIF(a.city,''), NULLIF(a.town_village,''), NULLIF(a.county_district,''), 'Not Specified') AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN LATERAL ( + SELECT ma.city, ma.town_village, ma.county_district + FROM m_client_address mca + JOIN m_address ma ON ma.id = mca.address_id + WHERE mca.client_id = c.id + AND mca.is_active = true AND mca.is_deleted = false + ORDER BY mca.id DESC LIMIT 1 + ) a ON true + WHERE sa.status_enum = 300 + GROUP BY label + ORDER BY cnt DESC + LIMIT 20 + """; + + // ── Caste / Social Category ─────────────────────────────────────────────── + private static final String SQL_CASTE = """ + SELECT COALESCE(cv.code_value, 'Not Specified') AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_code_value cv ON c.social_category_cv_id = cv.id + WHERE sa.status_enum = 300 + GROUP BY cv.code_value + ORDER BY cnt DESC + """; + + // ── Share holding distribution ──────────────────────────────────────────── + private static final String SQL_SHARE_HOLDING = """ + SELECT + CASE + WHEN sa.total_approved_shares IS NULL OR sa.total_approved_shares = 0 THEN 'Not Specified' + WHEN sa.total_approved_shares >= 500 THEN '500+ shares' + WHEN sa.total_approved_shares >= 100 THEN '100–499 shares' + WHEN sa.total_approved_shares >= 50 THEN '50–99 shares' + WHEN sa.total_approved_shares >= 10 THEN '10–49 shares' + ELSE '1–9 shares' + END AS label, + COUNT(DISTINCT sa.client_id) AS cnt, + MIN(COALESCE(sa.total_approved_shares, 0)) AS sort_key + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + WHERE sa.status_enum = 300 + GROUP BY label + ORDER BY sort_key DESC + """; + + // ── Member tenure ───────────────────────────────────────────────────────── + private static final String SQL_TENURE = """ + SELECT + CASE + WHEN c.activation_date IS NULL THEN 'Not Specified' + WHEN c.activation_date >= CURRENT_DATE - INTERVAL '2 years' THEN 'Under 2 years' + WHEN c.activation_date >= CURRENT_DATE - INTERVAL '5 years' THEN '2–5 years' + WHEN c.activation_date >= CURRENT_DATE - INTERVAL '10 years' THEN '5–10 years' + ELSE '10+ years' + END AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + WHERE sa.status_enum = 300 + GROUP BY label + ORDER BY cnt DESC + """; + + // ── Loan engagement ─────────────────────────────────────────────────────── + private static final String SQL_LOAN = """ + SELECT + CASE WHEN l.id IS NOT NULL THEN 'Active Loan Holders' ELSE 'No Active Loan' END AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_loan l ON l.client_id = c.id AND l.loan_status_id = 300 + WHERE sa.status_enum = 300 + GROUP BY CASE WHEN l.id IS NOT NULL THEN 'Active Loan Holders' ELSE 'No Active Loan' END + ORDER BY cnt DESC + """; + + // base for age-range eligibility — clauses added dynamically + private static final String SQL_ELIGIBLE_AGE_BASE = """ + SELECT COUNT(DISTINCT sa.client_id) + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + WHERE sa.status_enum = 300 + AND c.date_of_birth IS NOT NULL + """; + + // ── Sub-caste breakdown ─────────────────────────────────────────────────── + private static final String SQL_SUB_CASTE = """ + SELECT COALESCE(cv.code_value, 'Not Specified') AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_code_value cv ON c.sub_caste_cv_id = cv.id + WHERE sa.status_enum = 300 + GROUP BY cv.code_value + ORDER BY cnt DESC + """; + + // ── Religion breakdown ──────────────────────────────────────────────────── + private static final String SQL_RELIGION = """ + SELECT COALESCE(cv.code_value, 'Not Specified') AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_code_value cv ON c.religion_cv_id = cv.id + WHERE sa.status_enum = 300 + GROUP BY cv.code_value + ORDER BY cnt DESC + """; + + // ── Occupation category breakdown ───────────────────────────────────────── + private static final String SQL_OCCUPATION = """ + SELECT COALESCE(cv.code_value, 'Not Specified') AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_code_value cv ON c.occupation_category_cv_id = cv.id + WHERE sa.status_enum = 300 + GROUP BY cv.code_value + ORDER BY cnt DESC + """; + + // ── Education Level ─────────────────────────────────────────────────────── + private static final String SQL_EDUCATION = """ + SELECT COALESCE(cv.code_value, 'Not Specified') AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_code_value cv ON c.education_level_cv_id = cv.id + WHERE sa.status_enum = 300 + GROUP BY cv.code_value + ORDER BY cnt DESC + """; + + // ── Marital Status ──────────────────────────────────────────────────────── + private static final String SQL_MARITAL = """ + SELECT COALESCE(cv.code_value, 'Not Specified') AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_code_value cv ON c.marital_status_cv_id = cv.id + WHERE sa.status_enum = 300 + GROUP BY cv.code_value + ORDER BY cnt DESC + """; + + // ── Monthly Income tiers ────────────────────────────────────────────────── + private static final String SQL_MONTHLY_INCOME = """ + SELECT + CASE + WHEN c.monthly_income IS NULL OR c.monthly_income = 0 THEN 'Not Specified' + WHEN c.monthly_income >= 100000 THEN '₹1L+' + WHEN c.monthly_income >= 50000 THEN '₹50k–1L' + WHEN c.monthly_income >= 25000 THEN '₹25k–50k' + WHEN c.monthly_income >= 10000 THEN '₹10k–25k' + WHEN c.monthly_income >= 5000 THEN '₹5k–10k' + ELSE 'Under ₹5k' + END AS label, + COUNT(DISTINCT sa.client_id) AS cnt, + MIN(COALESCE(c.monthly_income, 0)) AS sort_key + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + WHERE sa.status_enum = 300 + GROUP BY label + ORDER BY sort_key DESC + """; + + // ── Savings account engagement ──────────────────────────────────────────── + private static final String SQL_SAVINGS = """ + SELECT + CASE WHEN sv.id IS NOT NULL THEN 'Active Savings' ELSE 'No Active Savings' END AS label, + COUNT(DISTINCT sa.client_id) AS cnt + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_savings_account sv ON sv.client_id = c.id AND sv.status_enum = 300 + WHERE sa.status_enum = 300 + GROUP BY CASE WHEN sv.id IS NOT NULL THEN 'Active Savings' ELSE 'No Active Savings' END + ORDER BY cnt DESC + """; + + // ── Family members ──────────────────────────────────────────────────────── + private static final String SQL_FAMILY = """ + SELECT + COUNT(DISTINCT CASE WHEN fm.id IS NOT NULL THEN sa.client_id END) AS with_family, + COUNT(fm.id) AS total_family + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_family_members fm ON fm.client_id = c.id + WHERE sa.status_enum = 300 + """; + + @Override + @Transactional(readOnly = true) + public CandidateVoterAnalysisData getCandidateVoterAnalysis(Long candidateId) { + // ── Candidate info ──────────────────────────────────────────────────── + Map info; + try { + info = jdbcTemplate.queryForMap(SQL_CANDIDATE_INFO, candidateId); + } catch (EmptyResultDataAccessException ex) { + throw new NotFoundException("Candidate not found with id: " + candidateId); + } + + String candidateName = str(info, "applicant_name"); + String candidateGender = str(info, "gender"); + String candidateCaste = str(info, "caste_category"); + String candidateSubCaste = str(info, "sub_caste"); + String candidateLocation = str(info, "location"); + Long electionId = toLong(info, "election_id"); + String postName = str(info, "post_name"); + Integer postMinAge = toInt(info, "min_age"); + Integer postMaxAge = toInt(info, "max_age"); + String postGenderType = str(info, "gender_type"); + Integer postMinShareHolding = toInt(info, "min_share_holding"); + // Candidate's city from m_client_address, used for area match + String candidateCity = str(info, "candidate_city"); + + // Candidate age from DOB + Integer candidateAge = null; + Object dob = info.get("date_of_birth"); + if (dob instanceof java.sql.Date) { + LocalDate dobDate = ((java.sql.Date) dob).toLocalDate(); + candidateAge = Period.between(dobDate, LocalDate.now()).getYears(); + } + + // ── Voter pool ───────────────────────────────────────────────────────── + long total = jdbcTemplate.queryForObject(SQL_TOTAL, Long.class); + + // ── Gender ───────────────────────────────────────────────────────────── + List genderBreakdown = buildBreakdown(SQL_GENDER, total, candidateGender, true); + if (candidateGender != null && !candidateGender.isBlank()) { + boolean hasGenderMatch = genderBreakdown.stream().anyMatch(BreakdownItem::isMatch); + if (!hasGenderMatch) { + genderBreakdown.add(new BreakdownItem(candidateGender, 0, 0.0, true)); + } + } + + // ── Age ──────────────────────────────────────────────────────────────── + List ageBreakdown = buildAgeBreakdown(total, candidateAge); + + long eligibleByAge = buildEligibleByAgeCount(postMinAge, postMaxAge, total); + + // ── Area ─────────────────────────────────────────────────────────────── + // Match uses candidate's city from m_client_address (not free-text nomination location) + List areaBreakdown = buildBreakdown(SQL_AREA, total, candidateCity, false); + long areaMatchCount = areaBreakdown.stream() + .filter(BreakdownItem::isMatch).mapToLong(BreakdownItem::getCount).sum(); + + // ── Caste ────────────────────────────────────────────────────────────── + List casteBreakdown = buildBreakdown(SQL_CASTE, total, candidateCaste, true); + // If candidate's own category has 0 shareholders it won't appear in the GROUP BY result. + // Add an explicit zero row so the UI can show "Candidate: OBC — 0 shareholders". + if (candidateCaste != null && !candidateCaste.isBlank()) { + boolean hasMatch = casteBreakdown.stream().anyMatch(BreakdownItem::isMatch); + if (!hasMatch) { + casteBreakdown.add(new BreakdownItem(candidateCaste, 0, 0.0, true)); + } + } + long casteMatchCount = casteBreakdown.stream() + .filter(BreakdownItem::isMatch).mapToLong(BreakdownItem::getCount).sum(); + + // ── Sub-caste ────────────────────────────────────────────────────────── + List subCasteBreakdown = buildBreakdown(SQL_SUB_CASTE, total, candidateSubCaste, true); + if (candidateSubCaste != null && !candidateSubCaste.isBlank()) { + boolean hasSubCasteMatch = subCasteBreakdown.stream().anyMatch(BreakdownItem::isMatch); + if (!hasSubCasteMatch) { + subCasteBreakdown.add(new BreakdownItem(candidateSubCaste, 0, 0.0, true)); + } + } + long subCasteMatchCount = subCasteBreakdown.stream() + .filter(BreakdownItem::isMatch).mapToLong(BreakdownItem::getCount).sum(); + + // ── Religion ─────────────────────────────────────────────────────────── + List religionBreakdown = buildBreakdown(SQL_RELIGION, total, null, false); + + // ── Occupation ───────────────────────────────────────────────────────── + List occupationBreakdown = buildBreakdown(SQL_OCCUPATION, total, null, false); + + // ── Share holding distribution ───────────────────────────────────────── + List shareHoldingBreakdown = buildBreakdown(SQL_SHARE_HOLDING, total, null, false); + + // ── Member tenure ────────────────────────────────────────────────────── + List tenureBreakdown = buildBreakdown(SQL_TENURE, total, null, false); + + // ── Loan engagement ──────────────────────────────────────────────────── + List loanHolderBreakdown = buildBreakdown(SQL_LOAN, total, null, false); + long activeLoanHolders = loanHolderBreakdown.stream() + .filter(i -> "Active Loan Holders".equals(i.getLabel())) + .mapToLong(BreakdownItem::getCount).sum(); + + // ── Family members ───────────────────────────────────────────────────── + Map familyRow = jdbcTemplate.queryForMap(SQL_FAMILY); + long shareholdersWithFamily = toLong(familyRow, "with_family"); + long totalFamilyMembers = toLong(familyRow, "total_family"); + + // ── Education ────────────────────────────────────────────────────────── + List educationBreakdown = buildBreakdown(SQL_EDUCATION, total, null, false); + + // ── Marital Status ───────────────────────────────────────────────────── + List maritalBreakdown = buildBreakdown(SQL_MARITAL, total, null, false); + + // ── Monthly Income ───────────────────────────────────────────────────── + List monthlyIncomeBreakdown = buildBreakdown(SQL_MONTHLY_INCOME, total, null, false); + + // ── Savings ──────────────────────────────────────────────────────────── + List savingsBreakdown = buildBreakdown(SQL_SAVINGS, total, "Active Savings", false); + long activeSavingsHolders = savingsBreakdown.stream() + .filter(i -> "Active Savings".equals(i.getLabel())) + .mapToLong(BreakdownItem::getCount).sum(); + + // ── Cross-dimension match (caste + area) ─────────────────────────────── + long crossMatchCount = buildCrossMatchCount(candidateCaste, candidateCity); + + // ── Actual eligible voters (all post criteria combined) ──────────────── + long actualEligibleVoters = buildActualEligibleCount(postGenderType, postMinAge, postMaxAge, postMinShareHolding); + + return CandidateVoterAnalysisData.builder() + .candidateId(candidateId) + .electionId(electionId) + .candidateName(candidateName) + .postName(postName) + .candidateGender(candidateGender) + .candidateCaste(candidateCaste) + .candidateLocation(candidateLocation) + .candidateArea(candidateCity) + .candidateAge(candidateAge) + // Post criteria + .postGenderType(postGenderType) + .postMinAge(postMinAge) + .postMaxAge(postMaxAge) + .postMinShareHolding(postMinShareHolding) + // Voter pool + .totalActiveShareholders(total) + .actualEligibleVoters(actualEligibleVoters) + // Gender + .genderBreakdown(genderBreakdown) + // Age + .ageBreakdown(ageBreakdown) + .eligibleByAge(eligibleByAge) + // Area + .areaBreakdown(areaBreakdown) + .areaMatchCount(areaMatchCount) + // Caste + .casteBreakdown(casteBreakdown) + .casteMatchCount(casteMatchCount) + // Sub-caste + .candidateSubCaste(candidateSubCaste) + .subCasteBreakdown(subCasteBreakdown) + .subCasteMatchCount(subCasteMatchCount) + // Religion + .religionBreakdown(religionBreakdown) + // Occupation + .occupationBreakdown(occupationBreakdown) + // Share holdings + .shareHoldingBreakdown(shareHoldingBreakdown) + // Tenure + .tenureBreakdown(tenureBreakdown) + // Loan + .loanHolderBreakdown(loanHolderBreakdown) + .activeLoanHolders(activeLoanHolders) + // Family + .shareholdersWithFamily(shareholdersWithFamily) + .totalFamilyMembers(totalFamilyMembers) + // Education + .educationBreakdown(educationBreakdown) + // Marital + .maritalBreakdown(maritalBreakdown) + // Monthly Income + .monthlyIncomeBreakdown(monthlyIncomeBreakdown) + // Savings + .savingsBreakdown(savingsBreakdown) + .activeSavingsHolders(activeSavingsHolders) + // Cross-match + .crossMatchCount(crossMatchCount) + .build(); + } + + /** + * Counts shareholders satisfying ALL provided post criteria simultaneously. + * Uses dynamic SQL so absent criteria are simply skipped. + */ + /** + * Returns count of shareholders within the post's age window. + * If no age criteria → returns total (no restriction = everyone eligible). + */ + private long buildEligibleByAgeCount(Integer minAge, Integer maxAge, long total) { + if (minAge == null && maxAge == null) { + return total; // open post — no age restriction + } + StringBuilder sql = new StringBuilder(SQL_ELIGIBLE_AGE_BASE); + List params = new ArrayList<>(); + if (minAge != null) { + sql.append("AND EXTRACT(YEAR FROM AGE(CURRENT_DATE, c.date_of_birth)) >= ? "); + params.add(minAge); + } + if (maxAge != null) { + sql.append("AND EXTRACT(YEAR FROM AGE(CURRENT_DATE, c.date_of_birth)) <= ? "); + params.add(maxAge); + } + Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray()); + return count != null ? count : 0; + } + + private long buildActualEligibleCount(String genderType, Integer minAge, Integer maxAge, Integer minShares) { + boolean hasGender = genderType != null && !genderType.isBlank() && !"ANY".equalsIgnoreCase(genderType); + boolean hasMinAge = minAge != null; + boolean hasMaxAge = maxAge != null; + boolean hasShares = minShares != null && minShares > 0; + + // If no criteria are defined the post is open to all shareholders + if (!hasGender && !hasMinAge && !hasMaxAge && !hasShares) { + Long count = jdbcTemplate.queryForObject(SQL_TOTAL, Long.class); + return count != null ? count : 0; + } + + StringBuilder sql = new StringBuilder(""" + SELECT COUNT(DISTINCT sa.client_id) + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_code_value gender_cv ON c.gender_cv_id = gender_cv.id + WHERE sa.status_enum = 300 + """); + List params = new ArrayList<>(); + + if (hasGender) { + sql.append("AND UPPER(COALESCE(gender_cv.code_value, '')) = ? "); + params.add(genderType.toUpperCase()); + } + if (hasMinAge) { + sql.append("AND c.date_of_birth IS NOT NULL "); + sql.append("AND EXTRACT(YEAR FROM AGE(CURRENT_DATE, c.date_of_birth)) >= ? "); + params.add(minAge); + } + if (hasMaxAge) { + sql.append("AND c.date_of_birth IS NOT NULL "); + sql.append("AND EXTRACT(YEAR FROM AGE(CURRENT_DATE, c.date_of_birth)) <= ? "); + params.add(maxAge); + } + if (hasShares) { + sql.append("AND COALESCE(sa.total_approved_shares, 0) >= ? "); + params.add(minShares); + } + + Long count = jdbcTemplate.queryForObject(sql.toString(), Long.class, params.toArray()); + return count != null ? count : 0; + } + + private long buildCrossMatchCount(String caste, String city) { + if (caste == null || caste.isBlank() || city == null || city.isBlank()) return 0; + String sql = """ + SELECT COUNT(DISTINCT sa.client_id) + FROM m_share_account sa + JOIN m_client c ON sa.client_id = c.id AND c.status_enum = 300 + LEFT JOIN m_code_value caste_cv ON c.social_category_cv_id = caste_cv.id + LEFT JOIN LATERAL ( + SELECT COALESCE(NULLIF(ma.city,''), NULLIF(ma.town_village,''), NULLIF(ma.county_district,'')) AS city + FROM m_client_address mca + JOIN m_address ma ON ma.id = mca.address_id + WHERE mca.client_id = c.id AND mca.is_active = true AND mca.is_deleted = false + ORDER BY mca.id DESC LIMIT 1 + ) addr ON true + WHERE sa.status_enum = 300 + AND UPPER(COALESCE(caste_cv.code_value, '')) = ? + AND addr.city = ? + """; + Long count = jdbcTemplate.queryForObject(sql, Long.class, caste.toUpperCase(), city); + return count != null ? count : 0; + } + + private List buildBreakdown(String sql, long total, String matchLabel, boolean caseInsensitive) { + List> rows = jdbcTemplate.queryForList(sql); + List items = new ArrayList<>(); + for (Map row : rows) { + String label = str(row, "label"); + long cnt = toLong(row, "cnt"); + double pct = total > 0 ? Math.round((cnt * 1000.0 / total)) / 10.0 : 0; + boolean match = matchLabel != null && !matchLabel.isBlank() + && (caseInsensitive ? matchLabel.equalsIgnoreCase(label) : matchLabel.equals(label)); + items.add(new BreakdownItem(label, cnt, pct, match)); + } + return items; + } + + private List buildAgeBreakdown(long total, Integer candidateAge) { + List> rows = jdbcTemplate.queryForList(SQL_AGE); + String candidateRange = null; + if (candidateAge != null) { + candidateRange = candidateAge < 25 ? "Under 25" + : candidateAge < 35 ? "25–34" + : candidateAge < 45 ? "35–44" + : candidateAge < 55 ? "45–54" + : candidateAge < 65 ? "55–64" + : "65+"; + } + List items = new ArrayList<>(); + for (Map row : rows) { + String label = str(row, "label"); + long cnt = toLong(row, "cnt"); + double pct = total > 0 ? Math.round((cnt * 1000.0 / total)) / 10.0 : 0; + boolean match = label.equals(candidateRange); + items.add(new BreakdownItem(label, cnt, pct, match)); + } + return items; + } + + private static String str(Map row, String key) { + Object v = row.get(key); + return v == null ? null : v.toString().trim(); + } + + private static long toLong(Map row, String key) { + Object v = row.get(key); + if (v == null) return 0; + if (v instanceof Number) return ((Number) v).longValue(); + try { return Long.parseLong(v.toString()); } catch (Exception e) { return 0; } + } + + private static Integer toInt(Map row, String key) { + Object v = row.get(key); + if (v == null) return null; + if (v instanceof Number) return ((Number) v).intValue(); + try { return Integer.parseInt(v.toString()); } catch (Exception e) { return null; } + } +} 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 990c7e3dc..4946ad71b 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 @@ -369,4 +369,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_election_post_add_eligibility_columns.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_election_post_add_eligibility_columns.xml new file mode 100644 index 000000000..8ac8a0ac1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0342_election_post_add_eligibility_columns.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0343_client_sub_caste_column.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0343_client_sub_caste_column.xml new file mode 100644 index 000000000..af5e76fd8 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0343_client_sub_caste_column.xml @@ -0,0 +1,138 @@ + + + + + + + SELECT COUNT(*) FROM m_code WHERE code_name = 'SubCaste' + + INSERT INTO m_code (code_name, is_system_defined) VALUES ('SubCaste', false) + + + + + + + SELECT COUNT(*) FROM m_code_value cv + JOIN m_code mc ON cv.code_id = mc.id + WHERE mc.code_name = 'SubCaste' AND cv.code_value = 'Brahmin' + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT mc.id, vals.code_value, vals.pos, true, false + FROM m_code mc, + (VALUES + ('Brahmin', 1), + ('Kshatriya / Rajput', 2), + ('Vaishya / Baniya', 3), + ('Kayastha', 4), + ('Jat', 5), + ('Sindhi', 6), + ('Other General', 7) + ) AS vals(code_value, pos) + WHERE mc.code_name = 'SubCaste'; + + + + + + + + SELECT COUNT(*) FROM m_code_value cv + JOIN m_code mc ON cv.code_id = mc.id + WHERE mc.code_name = 'SubCaste' AND cv.code_value = 'Yadav' + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT mc.id, vals.code_value, vals.pos, true, false + FROM m_code mc, + (VALUES + ('Yadav', 10), + ('Kurmi', 11), + ('Teli', 12), + ('Nai / Napit', 13), + ('Kewat / Dhimar', 14), + ('Kumhar', 15), + ('Kushwaha / Patel',16), + ('Mali', 17), + ('Lohar', 18), + ('Prajapati', 19), + ('Banjara', 20), + ('Other OBC', 21) + ) AS vals(code_value, pos) + WHERE mc.code_name = 'SubCaste'; + + + + + + + + SELECT COUNT(*) FROM m_code_value cv + JOIN m_code mc ON cv.code_id = mc.id + WHERE mc.code_name = 'SubCaste' AND cv.code_value = 'Chamar / Jatav' + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT mc.id, vals.code_value, vals.pos, true, false + FROM m_code mc, + (VALUES + ('Chamar / Jatav', 30), + ('Balmiki', 31), + ('Dhobi', 32), + ('Pasi', 33), + ('Meghwal', 34), + ('Kori', 35), + ('Other SC', 36) + ) AS vals(code_value, pos) + WHERE mc.code_name = 'SubCaste'; + + + + + + + + SELECT COUNT(*) FROM m_code_value cv + JOIN m_code mc ON cv.code_id = mc.id + WHERE mc.code_name = 'SubCaste' AND cv.code_value = 'Gond' + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT mc.id, vals.code_value, vals.pos, true, false + FROM m_code mc, + (VALUES + ('Gond', 40), + ('Bhil', 41), + ('Korku', 42), + ('Baiga', 43), + ('Bharia', 44), + ('Sahariya',45), + ('Other ST',46) + ) AS vals(code_value, pos) + WHERE mc.code_name = 'SubCaste'; + + + + + + + + + + + + + + + +