feat(apex): India → zone → state → district, and bank → branch

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) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-06 18:10:51 +05:30
co-authored by Claude Opus 5
parent 1e5f446672
commit 3531e6e79f
5 changed files with 232 additions and 1 deletions
@@ -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.
*
* <p>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<List<Map<String, Object>>> 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<List<Map<String, Object>>> bankBranches(
TenantContext tenant, @PathVariable String code) {
requireApex(tenant);
return ApiResponse.ok(apexService.bankBranches(code));
}
@GetMapping("/digest")
public ApiResponse<Map<String, Object>> digest(TenantContext tenant) {
requireApex(tenant);
@@ -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.
*
* <p>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<Map<String, Object>> geography(String level, String zone, String state) {
Map<Long, District> districts = new HashMap<>();
districtRepository.findAll().forEach(d -> districts.put(d.getId(), d));
Map<String, List<Enterprise>> grouped = new LinkedHashMap<>();
Map<String, Map<String, String>> 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<Map<String, Object>> out = new ArrayList<>();
grouped.forEach((key, list) -> {
Map<String, Object> 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.
*
* <p>This is the deepest NABARD may go. The level below a branch is a
* borrower, and that belongs to the bank.
*/
public List<Map<String, Object>> bankBranches(String institutionCode) {
Institution bank = institutionRepository.findByCode(institutionCode)
.orElseThrow(() -> new IllegalArgumentException("No institution " + institutionCode));
Map<Long, List<Enterprise>> byBranch = new LinkedHashMap<>();
enterpriseRepository.findByInstitutionIdOrderByScoreDesc(bank.getId())
.forEach(e -> byBranch.computeIfAbsent(e.getBranchId(), k -> new ArrayList<>()).add(e));
Map<Long, String> districtNames = new HashMap<>();
districtRepository.findAll().forEach(d -> districtNames.put(d.getId(), d.getName()));
List<Map<String, Object>> out = new ArrayList<>();
for (Branch branch : branchRepository.findByInstitutionIdAndActiveTrueOrderByName(bank.getId())) {
List<Enterprise> book = byBranch.getOrDefault(branch.getId(), List.of());
Map<String, Object> 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<Map<String, Object>> byDistrict() {
Map<Long, String> names = new HashMap<>();
@@ -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.
*
* <p>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;
@@ -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.
*
* <p>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<String, String> 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";
};
}
}
@@ -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();
}
/**