feat(election): candidate voter analysis API — multi-dimension breakdown + sub-caste migration

- 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 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-30 11:54:01 +05:30
co-authored by Claude Sonnet 4.6
parent 34f84e1fc6
commit 388af0a6a3
7 changed files with 941 additions and 0 deletions
@@ -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
// -------------------------------------------------------------------------
@@ -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<BreakdownItem> genderBreakdown;
// ── Age breakdown ─────────────────────────────────────────────────────────
private List<BreakdownItem> ageBreakdown;
private long eligibleByAge;
// ── Area / Office breakdown ───────────────────────────────────────────────
private List<BreakdownItem> areaBreakdown;
private long areaMatchCount;
// ── Caste / Social Category breakdown ────────────────────────────────────
private List<BreakdownItem> casteBreakdown;
private long casteMatchCount;
// ── Sub-caste breakdown ───────────────────────────────────────────────────
private String candidateSubCaste;
private List<BreakdownItem> subCasteBreakdown;
private long subCasteMatchCount;
// ── Religion breakdown ────────────────────────────────────────────────────
private List<BreakdownItem> religionBreakdown;
// ── Occupation breakdown ──────────────────────────────────────────────────
private List<BreakdownItem> occupationBreakdown;
// ── Share holding distribution ────────────────────────────────────────────
private List<BreakdownItem> shareHoldingBreakdown;
// ── Member tenure breakdown ────────────────────────────────────────────────
private List<BreakdownItem> tenureBreakdown;
// ── Loan engagement ───────────────────────────────────────────────────────
private List<BreakdownItem> loanHolderBreakdown;
private long activeLoanHolders;
// ── Family members ────────────────────────────────────────────────────────
private long shareholdersWithFamily;
private long totalFamilyMembers;
// ── Education breakdown ───────────────────────────────────────────────────
private List<BreakdownItem> educationBreakdown;
// ── Marital status breakdown ──────────────────────────────────────────────
private List<BreakdownItem> maritalBreakdown;
// ── Monthly income distribution ───────────────────────────────────────────
private List<BreakdownItem> monthlyIncomeBreakdown;
// ── Savings account engagement ────────────────────────────────────────────
private List<BreakdownItem> 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;
}
}
@@ -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);
}
@@ -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<String, Object> 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<BreakdownItem> 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<BreakdownItem> 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<BreakdownItem> areaBreakdown = buildBreakdown(SQL_AREA, total, candidateCity, false);
long areaMatchCount = areaBreakdown.stream()
.filter(BreakdownItem::isMatch).mapToLong(BreakdownItem::getCount).sum();
// ── Caste ──────────────────────────────────────────────────────────────
List<BreakdownItem> 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<BreakdownItem> 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<BreakdownItem> religionBreakdown = buildBreakdown(SQL_RELIGION, total, null, false);
// ── Occupation ─────────────────────────────────────────────────────────
List<BreakdownItem> occupationBreakdown = buildBreakdown(SQL_OCCUPATION, total, null, false);
// ── Share holding distribution ─────────────────────────────────────────
List<BreakdownItem> shareHoldingBreakdown = buildBreakdown(SQL_SHARE_HOLDING, total, null, false);
// ── Member tenure ──────────────────────────────────────────────────────
List<BreakdownItem> tenureBreakdown = buildBreakdown(SQL_TENURE, total, null, false);
// ── Loan engagement ────────────────────────────────────────────────────
List<BreakdownItem> 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<String, Object> familyRow = jdbcTemplate.queryForMap(SQL_FAMILY);
long shareholdersWithFamily = toLong(familyRow, "with_family");
long totalFamilyMembers = toLong(familyRow, "total_family");
// ── Education ──────────────────────────────────────────────────────────
List<BreakdownItem> educationBreakdown = buildBreakdown(SQL_EDUCATION, total, null, false);
// ── Marital Status ─────────────────────────────────────────────────────
List<BreakdownItem> maritalBreakdown = buildBreakdown(SQL_MARITAL, total, null, false);
// ── Monthly Income ─────────────────────────────────────────────────────
List<BreakdownItem> monthlyIncomeBreakdown = buildBreakdown(SQL_MONTHLY_INCOME, total, null, false);
// ── Savings ────────────────────────────────────────────────────────────
List<BreakdownItem> 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<Object> 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<Object> 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<BreakdownItem> buildBreakdown(String sql, long total, String matchLabel, boolean caseInsensitive) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
List<BreakdownItem> items = new ArrayList<>();
for (Map<String, Object> 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<BreakdownItem> buildAgeBreakdown(long total, Integer candidateAge) {
List<Map<String, Object>> 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<BreakdownItem> items = new ArrayList<>();
for (Map<String, Object> 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<String, Object> row, String key) {
Object v = row.get(key);
return v == null ? null : v.toString().trim();
}
private static long toLong(Map<String, Object> 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<String, Object> 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; }
}
}
@@ -369,4 +369,6 @@
<include file="parts/0339_election_ceo_precheck_family_loan_check.xml" relativeToChangelogFile="true"/>
<include file="parts/0340_election_candidate_symbol_text.xml" relativeToChangelogFile="true"/>
<include file="parts/0341_election_audit_log.xml" relativeToChangelogFile="true"/>
<include file="parts/0342_election_post_add_eligibility_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0343_client_sub_caste_column.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,59 @@
<?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">
<!-- Add gender_type if not present -->
<changeSet id="0342_election_post_add_gender_type" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_post" columnName="gender_type"/></not>
</preConditions>
<addColumn tableName="m_election_post">
<column name="gender_type" type="VARCHAR(20)" defaultValue="ANY">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<!-- Add min_age if not present -->
<changeSet id="0342_election_post_add_min_age" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_post" columnName="min_age"/></not>
</preConditions>
<addColumn tableName="m_election_post">
<column name="min_age" type="INT"/>
</addColumn>
</changeSet>
<!-- Add max_age if not present -->
<changeSet id="0342_election_post_add_max_age" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_post" columnName="max_age"/></not>
</preConditions>
<addColumn tableName="m_election_post">
<column name="max_age" type="INT"/>
</addColumn>
</changeSet>
<!-- Add min_share_holding if not present -->
<changeSet id="0342_election_post_add_min_share_holding" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_post" columnName="min_share_holding"/></not>
</preConditions>
<addColumn tableName="m_election_post">
<column name="min_share_holding" type="INT" defaultValueNumeric="0"/>
</addColumn>
</changeSet>
<!-- Add area if not present -->
<changeSet id="0342_election_post_add_area" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_post" columnName="area"/></not>
</preConditions>
<addColumn tableName="m_election_post">
<column name="area" type="VARCHAR(200)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,138 @@
<?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">
<!-- Add SubCaste master code -->
<changeSet id="0343-1" author="election-analytics">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_code WHERE code_name = 'SubCaste'</sqlCheck>
</preConditions>
<sql>INSERT INTO m_code (code_name, is_system_defined) VALUES ('SubCaste', false)</sql>
</changeSet>
<!-- Add sub-caste values: General -->
<changeSet id="0343-2" author="election-analytics">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
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'
</sqlCheck>
</preConditions>
<sql>
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';
</sql>
</changeSet>
<!-- Add sub-caste values: OBC -->
<changeSet id="0343-3" author="election-analytics">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
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'
</sqlCheck>
</preConditions>
<sql>
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';
</sql>
</changeSet>
<!-- Add sub-caste values: SC -->
<changeSet id="0343-4" author="election-analytics">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
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'
</sqlCheck>
</preConditions>
<sql>
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';
</sql>
</changeSet>
<!-- Add sub-caste values: ST -->
<changeSet id="0343-5" author="election-analytics">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">
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'
</sqlCheck>
</preConditions>
<sql>
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';
</sql>
</changeSet>
<!-- Add sub_caste_cv_id column to m_client -->
<changeSet id="0343-6" author="election-analytics">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client" columnName="sub_caste_cv_id"/></not>
</preConditions>
<addColumn tableName="m_client">
<column name="sub_caste_cv_id" type="BIGINT">
<constraints nullable="true"
foreignKeyName="fk_client_sub_caste"
references="m_code_value(id)"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>