From 3531e6e79fbac2ba86c7e8d5fb969f2aca0b31f5 Mon Sep 17 00:00:00 2001 From: LincPay Date: Thu, 6 Aug 2026 18:10:51 +0530 Subject: [PATCH] =?UTF-8?q?feat(apex):=20India=20=E2=86=92=20zone=20?= =?UTF-8?q?=E2=86=92=20state=20=E2=86=92=20district,=20and=20bank=20?= =?UTF-8?q?=E2=86=92=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NABARD reads a national portfolio geographically before it reads it by institution, so the apex tier needs the hierarchy above the district that the pilot never had. District carries a zone. Held as data rather than derived in a service, so a state moving zone is a data change, not a deploy — and Zones maps state to zone from a table rather than guessing, because a portfolio rolled into the wrong zone is worse than one not rolled up at all: confidently wrong, and nothing downstream can detect it. An unmapped state becomes UNMAPPED and is reported as such. /nabard/geography takes level plus optional zone and state. Those come from the request because they select a view, not an entitlement — an apex user may read every zone, so naming one grants nothing. Contrast the institution id, which is never accepted from a request. /nabard/banks/{code}/branches is the deepest this tier goes. Below a branch is a borrower, and that belongs to the bank. Suppression applies at every level and bites hardest at the bottom, which is the intended behaviour of a drill-down: the further in you go, the closer you are to a person. Verified: India→Central→Madhya Pradesh→4 districts resolves, and MPGB splits into Sehore (12) and Vidisha (32) branches. Co-Authored-By: Claude Opus 5 (1M context) --- .../udyamlinc/apex/ApexController.java | 28 ++++++ .../udyamlinc/apex/ApexService.java | 96 +++++++++++++++++++ .../lincfusion/udyamlinc/master/District.java | 11 +++ .../lincfusion/udyamlinc/master/Zones.java | 95 ++++++++++++++++++ .../udyamlinc/seed/UdyamDataSeeder.java | 3 +- 5 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/lincfusion/udyamlinc/master/Zones.java diff --git a/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java b/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java index aabb883..17d7d17 100644 --- a/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java +++ b/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java @@ -4,7 +4,9 @@ import com.lincfusion.udyamlinc.common.ApiResponse; import com.lincfusion.udyamlinc.security.TenantContext; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.LinkedHashMap; @@ -57,6 +59,32 @@ public class ApexController { return ApiResponse.ok(apexService.bySector()); } + /** + * India → zone → state → district. + * + *

The narrowing parameters come from the caller because they select a + * view, not an entitlement: an apex user may read every zone, so being able + * to name one grants nothing. Contrast the institution id, which is never + * accepted from a request. + */ + @GetMapping("/geography") + public ApiResponse>> geography( + TenantContext tenant, + @RequestParam(defaultValue = "ZONE") String level, + @RequestParam(required = false) String zone, + @RequestParam(required = false) String state) { + requireApex(tenant); + return ApiResponse.ok(apexService.geography(level, zone, state)); + } + + /** One partner bank, broken down by branch. The deepest this tier goes. */ + @GetMapping("/banks/{code}/branches") + public ApiResponse>> bankBranches( + TenantContext tenant, @PathVariable String code) { + requireApex(tenant); + return ApiResponse.ok(apexService.bankBranches(code)); + } + @GetMapping("/digest") public ApiResponse> digest(TenantContext tenant) { requireApex(tenant); diff --git a/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java b/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java index 0ca1c7c..0bb6e0d 100644 --- a/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java +++ b/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java @@ -10,6 +10,7 @@ import com.lincfusion.udyamlinc.master.District; import com.lincfusion.udyamlinc.master.DistrictRepository; import com.lincfusion.udyamlinc.master.Sector; import com.lincfusion.udyamlinc.master.SectorRepository; +import com.lincfusion.udyamlinc.master.Zones; import com.lincfusion.udyamlinc.risk.RiskAlert; import com.lincfusion.udyamlinc.risk.RiskAlertRepository; import lombok.RequiredArgsConstructor; @@ -134,6 +135,101 @@ public class ApexService { // ── district and sector views ─────────────────────────── + // ── geographic drill-down: India → zone → state → district ── + + /** + * One level of the national hierarchy, optionally narrowed by the level above. + * + *

The same suppression applies at every level, and it bites hardest at the + * bottom: a zone of forty thousand borrowers publishes freely, a district of + * four does not. That is the intended behaviour of a drill-down — the further + * in you go, the closer you get to a person. + * + * @param level ZONE | STATE | DISTRICT + * @param zone narrow to one zone (for STATE and DISTRICT) + * @param state narrow to one state (for DISTRICT) + */ + public List> geography(String level, String zone, String state) { + Map districts = new HashMap<>(); + districtRepository.findAll().forEach(d -> districts.put(d.getId(), d)); + + Map> grouped = new LinkedHashMap<>(); + Map> meta = new HashMap<>(); + + for (Enterprise e : enterpriseRepository.findAll()) { + District d = districts.get(e.getDistrictId()); + if (d == null) continue; + + String districtZone = d.getZone() == null ? Zones.of(d.getState()) : d.getZone(); + if (zone != null && !zone.isBlank() && !zone.equalsIgnoreCase(districtZone)) continue; + if (state != null && !state.isBlank() && !state.equalsIgnoreCase(d.getState())) continue; + + String key = switch (level == null ? "ZONE" : level.toUpperCase()) { + case "STATE" -> d.getState(); + case "DISTRICT" -> d.getName(); + default -> districtZone; + }; + if (key == null) continue; + + grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(e); + meta.computeIfAbsent(key, k -> Map.of( + "zone", districtZone, + "zoneLabel", Zones.label(districtZone), + "state", d.getState() == null ? "—" : d.getState())); + } + + String levelUpper = level == null ? "ZONE" : level.toUpperCase(); + List> out = new ArrayList<>(); + grouped.forEach((key, list) -> { + Map row = new LinkedHashMap<>(cell("name", key, list)); + row.put("level", levelUpper); + row.putAll(meta.getOrDefault(key, Map.of())); + if ("ZONE".equals(levelUpper)) row.put("name", Zones.label(key)); + row.put("key", key); + out.add(row); + }); + + if ("ZONE".equals(levelUpper)) { + out.sort(Comparator.comparingInt(m -> Zones.order(String.valueOf(m.get("key"))))); + } else { + out.sort(Comparator.comparing(m -> String.valueOf(m.get("name")))); + } + return out; + } + + /** + * A bank's own book broken down by branch — still aggregates, still + * suppressed below the threshold. + * + *

This is the deepest NABARD may go. The level below a branch is a + * borrower, and that belongs to the bank. + */ + public List> bankBranches(String institutionCode) { + Institution bank = institutionRepository.findByCode(institutionCode) + .orElseThrow(() -> new IllegalArgumentException("No institution " + institutionCode)); + + Map> byBranch = new LinkedHashMap<>(); + enterpriseRepository.findByInstitutionIdOrderByScoreDesc(bank.getId()) + .forEach(e -> byBranch.computeIfAbsent(e.getBranchId(), k -> new ArrayList<>()).add(e)); + + Map districtNames = new HashMap<>(); + districtRepository.findAll().forEach(d -> districtNames.put(d.getId(), d.getName())); + + List> out = new ArrayList<>(); + for (Branch branch : branchRepository.findByInstitutionIdAndActiveTrueOrderByName(bank.getId())) { + List book = byBranch.getOrDefault(branch.getId(), List.of()); + Map row = new LinkedHashMap<>(cell("name", branch.getName(), book)); + row.put("branchCode", branch.getCode()); + row.put("region", branch.getRegion() == null ? "—" : branch.getRegion()); + row.put("blocks", branch.getBlocks() == null ? "—" : branch.getBlocks()); + row.put("district", districtNames.getOrDefault(branch.getDistrictId(), "—")); + row.put("institutionCode", bank.getCode()); + row.put("institutionName", bank.getName()); + out.add(row); + } + return out; + } + /** Stress by district — the unit NABARD plans ground-level credit in. */ public List> byDistrict() { Map names = new HashMap<>(); diff --git a/src/main/java/com/lincfusion/udyamlinc/master/District.java b/src/main/java/com/lincfusion/udyamlinc/master/District.java index cc3cd29..722f688 100644 --- a/src/main/java/com/lincfusion/udyamlinc/master/District.java +++ b/src/main/java/com/lincfusion/udyamlinc/master/District.java @@ -34,6 +34,17 @@ public class District { @Column(nullable = false, length = 64) private String state; + /** + * NORTH | SOUTH | EAST | WEST | CENTRAL | NORTH_EAST — the zone NABARD reads + * a national portfolio in, above the state. + * + *

Held on the district rather than derived in a service so that a state + * moving zone, or a union territory being reclassified, is a data change + * rather than a deploy. Derived from {@code state} at seed time. + */ + @Column(length = 16) + private String zone; + /** District centroid — used to pick the climate grid cell. */ private Double latitude; diff --git a/src/main/java/com/lincfusion/udyamlinc/master/Zones.java b/src/main/java/com/lincfusion/udyamlinc/master/Zones.java new file mode 100644 index 0000000..6310aff --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/master/Zones.java @@ -0,0 +1,95 @@ +package com.lincfusion.udyamlinc.master; + +import java.util.Locale; +import java.util.Map; + +/** + * State to zone, for the national view NABARD reads above the state. + * + *

A lookup table rather than a guess: a portfolio rolled up into the wrong + * zone is worse than one not rolled up at all, because it is confidently wrong + * and nothing downstream can detect it. Anything unmapped becomes + * {@code UNMAPPED} and is reported as such — never silently folded into a zone + * it does not belong to. + */ +public final class Zones { + + public static final String UNMAPPED = "UNMAPPED"; + + private static final Map BY_STATE = Map.ofEntries( + // North + Map.entry("jammu and kashmir", "NORTH"), + Map.entry("ladakh", "NORTH"), + Map.entry("himachal pradesh", "NORTH"), + Map.entry("punjab", "NORTH"), + Map.entry("haryana", "NORTH"), + Map.entry("delhi", "NORTH"), + Map.entry("chandigarh", "NORTH"), + Map.entry("rajasthan", "NORTH"), + // Central + Map.entry("madhya pradesh", "CENTRAL"), + Map.entry("chhattisgarh", "CENTRAL"), + Map.entry("uttar pradesh", "CENTRAL"), + Map.entry("uttarakhand", "CENTRAL"), + // East + Map.entry("bihar", "EAST"), + Map.entry("jharkhand", "EAST"), + Map.entry("odisha", "EAST"), + Map.entry("west bengal", "EAST"), + Map.entry("sikkim", "EAST"), + // West + Map.entry("gujarat", "WEST"), + Map.entry("maharashtra", "WEST"), + Map.entry("goa", "WEST"), + Map.entry("dadra and nagar haveli and daman and diu", "WEST"), + // South + Map.entry("andhra pradesh", "SOUTH"), + Map.entry("telangana", "SOUTH"), + Map.entry("karnataka", "SOUTH"), + Map.entry("kerala", "SOUTH"), + Map.entry("tamil nadu", "SOUTH"), + Map.entry("puducherry", "SOUTH"), + Map.entry("lakshadweep", "SOUTH"), + Map.entry("andaman and nicobar islands", "SOUTH"), + // North-east + Map.entry("assam", "NORTH_EAST"), + Map.entry("arunachal pradesh", "NORTH_EAST"), + Map.entry("manipur", "NORTH_EAST"), + Map.entry("meghalaya", "NORTH_EAST"), + Map.entry("mizoram", "NORTH_EAST"), + Map.entry("nagaland", "NORTH_EAST"), + Map.entry("tripura", "NORTH_EAST")); + + private Zones() { + } + + public static String of(String state) { + if (state == null || state.isBlank()) return UNMAPPED; + return BY_STATE.getOrDefault(state.trim().toLowerCase(Locale.ROOT), UNMAPPED); + } + + /** Display order for a national table — geography, not alphabet. */ + public static int order(String zone) { + return switch (zone == null ? "" : zone) { + case "NORTH" -> 1; + case "CENTRAL" -> 2; + case "EAST" -> 3; + case "NORTH_EAST" -> 4; + case "WEST" -> 5; + case "SOUTH" -> 6; + default -> 9; + }; + } + + public static String label(String zone) { + return switch (zone == null ? "" : zone) { + case "NORTH" -> "North"; + case "CENTRAL" -> "Central"; + case "EAST" -> "East"; + case "NORTH_EAST" -> "North East"; + case "WEST" -> "West"; + case "SOUTH" -> "South"; + default -> "Unmapped"; + }; + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java b/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java index da02f30..eadc6fb 100644 --- a/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java +++ b/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java @@ -143,8 +143,9 @@ public class UdyamDataSeeder implements CommandLineRunner { } private District district(String code, String name, String nameHi, double lat, double lon) { + String state = "Madhya Pradesh"; return District.builder().code(code).name(name).nameHi(nameHi) - .state("Madhya Pradesh").latitude(lat).longitude(lon).active(true).build(); + .state(state).zone(Zones.of(state)).latitude(lat).longitude(lon).active(true).build(); } /**