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:
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user