diff --git a/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java b/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java index 17d7d17..a6fd671 100644 --- a/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java +++ b/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java @@ -32,31 +32,32 @@ import java.util.Map; public class ApexController { private final ApexService apexService; + private final RegionalOfficeService regionalOfficeService; @GetMapping("/dashboard") public ApiResponse> dashboard(TenantContext tenant) { requireApex(tenant); - Map out = new LinkedHashMap<>(apexService.national()); - out.put("dataAgeDays", apexService.dataAgeDays()); + Map out = new LinkedHashMap<>(apexService.national(tenant)); + out.put("dataAgeDays", apexService.dataAgeDays(tenant)); return ApiResponse.ok(out); } @GetMapping("/banks") public ApiResponse>> banks(TenantContext tenant) { requireApex(tenant); - return ApiResponse.ok(apexService.bankScorecard()); + return ApiResponse.ok(apexService.bankScorecard(tenant)); } @GetMapping("/districts") public ApiResponse>> districts(TenantContext tenant) { requireApex(tenant); - return ApiResponse.ok(apexService.byDistrict()); + return ApiResponse.ok(apexService.byDistrict(tenant)); } @GetMapping("/sectors") public ApiResponse>> sectors(TenantContext tenant) { requireApex(tenant); - return ApiResponse.ok(apexService.bySector()); + return ApiResponse.ok(apexService.bySector(tenant)); } /** @@ -74,7 +75,7 @@ public class ApexController { @RequestParam(required = false) String zone, @RequestParam(required = false) String state) { requireApex(tenant); - return ApiResponse.ok(apexService.geography(level, zone, state)); + return ApiResponse.ok(apexService.geography(level, zone, state, tenant)); } /** One partner bank, broken down by branch. The deepest this tier goes. */ @@ -82,19 +83,42 @@ public class ApexController { public ApiResponse>> bankBranches( TenantContext tenant, @PathVariable String code) { requireApex(tenant); - return ApiResponse.ok(apexService.bankBranches(code)); + return ApiResponse.ok(apexService.bankBranches(code, tenant)); } @GetMapping("/digest") public ApiResponse> digest(TenantContext tenant) { requireApex(tenant); - return ApiResponse.ok(apexService.digest()); + return ApiResponse.ok(apexService.digest(tenant)); } @GetMapping("/institutions") public ApiResponse>> institutions(TenantContext tenant) { requireApex(tenant); - return ApiResponse.ok(apexService.institutions()); + return ApiResponse.ok(apexService.institutions(tenant)); + } + + /** + * The Regional Offices this caller may see — all of them for head office, + * exactly one for an RO user. + */ + @GetMapping("/regional-offices") + public ApiResponse>> regionalOffices( + TenantContext tenant, + @RequestParam(defaultValue = "true") boolean populatedOnly) { + requireApex(tenant); + return ApiResponse.ok(regionalOfficeService.offices(tenant, populatedOnly)); + } + + /** + * The banks one office supervises. An office the caller is not entitled to + * returns 404, identical to one that does not exist. + */ + @GetMapping("/regional-offices/{code}/banks") + public ApiResponse>> regionalOfficeBanks( + TenantContext tenant, @PathVariable String code) { + requireApex(tenant); + return ApiResponse.ok(regionalOfficeService.banks(tenant, code)); } private void requireApex(TenantContext tenant) { diff --git a/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java b/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java index 0bb6e0d..ea366df 100644 --- a/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java +++ b/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java @@ -11,8 +11,10 @@ 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.institution.RegionalOfficeStateRepository; import com.lincfusion.udyamlinc.risk.RiskAlert; import com.lincfusion.udyamlinc.risk.RiskAlertRepository; +import com.lincfusion.udyamlinc.security.TenantContext; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -44,6 +46,8 @@ public class ApexService { private final InstitutionRepository institutionRepository; private final BranchRepository branchRepository; private final EnterpriseRepository enterpriseRepository; + private final RegionalOfficeStateRepository officeStateRepository; + private final com.lincfusion.udyamlinc.institution.RegionalOfficeRepository regionalOfficeRepository; private final DistrictRepository districtRepository; private final SectorRepository sectorRepository; private final RiskAlertRepository alertRepository; @@ -52,16 +56,87 @@ public class ApexService { @Value("${udyam.apex.min-cell-size:5}") private int minCellSize; + /** + * The same threshold for a Regional Office session. + * + *

Separate so it can be tuned per tier, and defaulted to the same value + * because the case for lowering it is weaker than it looks: a regional + * officer is closer to the borrowers than head office is, so a cell of three + * is easier for them to resolve to a person, not harder. + */ + @Value("${udyam.apex.min-cell-size.regional-office:5}") + private int minCellSizeRegional; + + /** The threshold that applies to this caller. */ + private int minCellFor(TenantContext tenant) { + return tenant != null && tenant.isRegionalOffice() ? minCellSizeRegional : minCellSize; + } + // ── national dashboard ────────────────────────────────── - /** Headline counts across every refinanced portfolio. */ - public Map national() { + /** + * The districts this apex caller may aggregate over, or {@code null} for head + * office, which may aggregate over all of them. + * + *

Every read on this surface goes through here. When the Regional Office + * tier was added, the two new endpoints were written to respect it and these + * eight older ones were not — so a Bengaluru officer, correctly barred from + * every borrower row, could still read Madhya Pradesh's bank aggregates. + * Nothing identified a borrower, so it was not a privacy breach; it defeated + * the supervisory boundary the tier exists to draw, which is bad enough. + * + *

Hence one choke point rather than eight guards. A scoping rule applied + * per endpoint is a rule that will be missed by endpoint nine. + */ + private Set scopeDistricts(TenantContext tenant) { + Long office = tenant == null ? null : tenant.regionalOfficeFilter(); + if (office == null) return null; + + Set states = new HashSet<>(); + officeStateRepository.findByRegionalOfficeId(office) + .forEach(s -> states.add(s.getState())); + + Set districts = new HashSet<>(); + districtRepository.findAll().forEach(d -> { + if (states.contains(d.getState())) districts.add(d.getId()); + }); + return districts; + } + + /** Every enterprise this caller may aggregate over. */ + private List inScope(TenantContext tenant) { + Set districts = scopeDistricts(tenant); List all = enterpriseRepository.findAll(); - List lenders = institutionRepository.findByTypeNotAndActiveTrueOrderByName("APEX"); + if (districts == null) return all; + return all.stream().filter(e -> districts.contains(e.getDistrictId())).toList(); + } + + /** One bank's book, narrowed to the caller's office. */ + private List bookInScope(Long institutionId, TenantContext tenant) { + Set districts = scopeDistricts(tenant); + List book = enterpriseRepository.findByInstitutionIdOrderByScoreDesc(institutionId); + if (districts == null) return book; + return book.stream().filter(e -> districts.contains(e.getDistrictId())).toList(); + } + + /** Headline counts across the refinanced portfolio this caller may see. */ + public Map national(TenantContext tenant) { + List all = inScope(tenant); + + // Counted from the book in scope, not from the register. A regional office + // supervising one bank must not read "8 partner banks" above its own + // twenty borrowers — the header would describe the country while every + // figure beneath it described Karnataka. + Set institutionsInScope = new HashSet<>(); + Set branchesInScope = new HashSet<>(); + all.forEach(e -> { + if (e.getInstitutionId() != null) institutionsInScope.add(e.getInstitutionId()); + if (e.getBranchId() != null) branchesInScope.add(e.getBranchId()); + }); Map out = new LinkedHashMap<>(); - out.put("partnerBanks", lenders.size()); - out.put("branches", branchRepository.count()); + out.put("partnerBanks", institutionsInScope.size()); + out.put("branches", branchesInScope.size()); out.put("enterprises", all.size()); out.put("red", count(all, "RED")); out.put("watch", count(all, "WATCH")); @@ -70,10 +145,16 @@ public class ApexService { out.put("portfolioValue", all.stream() .map(e -> e.getLoanOutstanding() == null ? BigDecimal.ZERO : e.getLoanOutstanding()) .reduce(BigDecimal.ZERO, BigDecimal::add)); + // Alerts are keyed by enterprise, so they need narrowing like everything + // else. Left unscoped this read 342 open alerts above a book of twenty — + // a number nobody could act on and nobody could reconcile. + Set idsInScope = new HashSet<>(); + all.forEach(e -> idsInScope.add(e.getEnterpriseId())); out.put("openAlerts", alertRepository.findAllByOrderByScoreDesc().stream() .filter(a -> !"CLOSED".equalsIgnoreCase(a.getState())) + .filter(a -> idsInScope.contains(a.getEnterpriseId())) .count()); - out.put("minCellSize", minCellSize); + out.put("minCellSize", minCellFor(tenant)); return out; } @@ -84,14 +165,19 @@ public class ApexService { * stress, and — the number that actually distinguishes a good partner from a * bad one — how much of what was flagged has been acted on. */ - public List> bankScorecard() { + public List> bankScorecard(TenantContext tenant) { Map latestAlertByEnterprise = new HashMap<>(); alertRepository.findAllByOrderByScoreDesc() .forEach(a -> latestAlertByEnterprise.putIfAbsent(a.getEnterpriseId(), a)); List> out = new ArrayList<>(); + boolean scoped = scopeDistricts(tenant) != null; for (Institution bank : institutionRepository.findByTypeNotAndActiveTrueOrderByName("APEX")) { - List book = enterpriseRepository.findByInstitutionIdOrderByScoreDesc(bank.getId()); + List book = bookInScope(bank.getId(), tenant); + // A bank with nothing in this office is not a bank with an empty book — + // it is a bank this office does not supervise, and listing it at zero + // would misreport both the bank and the office. + if (scoped && book.isEmpty()) continue; long raised = book.stream() .filter(e -> latestAlertByEnterprise.containsKey(e.getEnterpriseId())) @@ -109,9 +195,9 @@ public class ApexService { row.put("type", bank.getType()); row.put("branches", branchRepository.findByInstitutionIdAndActiveTrueOrderByName(bank.getId()).size()); row.put("enterprises", book.size()); - row.put("suppressed", book.size() < minCellSize); + row.put("suppressed", book.size() < minCellFor(tenant)); - if (book.size() < minCellSize) { + if (book.size() < minCellFor(tenant)) { // Below the threshold nothing else is published — see the class note. out.add(row); continue; @@ -145,26 +231,51 @@ public class ApexService { * 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) + *

RO is a different kind of level from the other three. Zone, state + * and district are nested geography; a Regional Office is an administrative + * boundary drawn over states, and it is the unit a NABARD user actually works + * in. It sits above state in the drill and does not always align with zone — + * Chandigarh supervises Punjab and Haryana, which is one office across a zone + * boundary. Offered as its own level rather than replacing zone, because the + * two answer different questions: zone is where the weather and the crop are, + * office is who is accountable. + * + * @param level RO | ZONE | STATE | DISTRICT + * @param zone narrow to one zone or one office code (for STATE and DISTRICT) * @param state narrow to one state (for DISTRICT) */ - public List> geography(String level, String zone, String state) { + public List> geography(String level, String zone, String state, TenantContext tenant) { Map districts = new HashMap<>(); districtRepository.findAll().forEach(d -> districts.put(d.getId(), d)); + // state → office, for the RO level and for narrowing by office code. + Map officeCodeByState = new HashMap<>(); + Map officeNameByCode = new HashMap<>(); + officeStateRepository.findAll().forEach(s -> + regionalOfficeRepository.findById(s.getRegionalOfficeId()).ifPresent(o -> { + officeCodeByState.put(s.getState(), o.getCode()); + officeNameByCode.put(o.getCode(), o.getName()); + })); + Map> grouped = new LinkedHashMap<>(); Map> meta = new HashMap<>(); - for (Enterprise e : enterpriseRepository.findAll()) { + for (Enterprise e : inScope(tenant)) { 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; + String officeCode = officeCodeByState.get(d.getState()); + + // The narrowing parameter carries either a zone or an office code, + // depending on which level the caller drilled from. + if (zone != null && !zone.isBlank() + && !zone.equalsIgnoreCase(districtZone) + && !zone.equalsIgnoreCase(officeCode)) continue; if (state != null && !state.isBlank() && !state.equalsIgnoreCase(d.getState())) continue; String key = switch (level == null ? "ZONE" : level.toUpperCase()) { + case "RO" -> officeCode; case "STATE" -> d.getState(); case "DISTRICT" -> d.getName(); default -> districtZone; @@ -175,16 +286,20 @@ public class ApexService { meta.computeIfAbsent(key, k -> Map.of( "zone", districtZone, "zoneLabel", Zones.label(districtZone), + "office", officeCode == null ? "—" : officeCode, "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)); + Map row = new LinkedHashMap<>(cell("name", key, list, tenant)); row.put("level", levelUpper); row.putAll(meta.getOrDefault(key, Map.of())); if ("ZONE".equals(levelUpper)) row.put("name", Zones.label(key)); + // The office name, not its code — "Madhya Pradesh Regional Office" + // rather than BHOPAL, so the row reads as the institution it is. + if ("RO".equals(levelUpper)) row.put("name", officeNameByCode.getOrDefault(key, key)); row.put("key", key); out.add(row); }); @@ -204,13 +319,21 @@ public class ApexService { *

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) { + public List> bankBranches(String institutionCode, TenantContext tenant) { Institution bank = institutionRepository.findByCode(institutionCode) .orElseThrow(() -> new IllegalArgumentException("No institution " + institutionCode)); + List supervised = bookInScope(bank.getId(), tenant); + // A bank outside this office reads as one that does not exist, matching + // how a cross-tenant borrower request behaves. A 403 here would confirm + // the bank is real and simply somebody else's. + if (supervised.isEmpty() && scopeDistricts(tenant) != null) { + throw new com.lincfusion.udyamlinc.common.ResourceNotFoundException( + "No such institution"); + } + Map> byBranch = new LinkedHashMap<>(); - enterpriseRepository.findByInstitutionIdOrderByScoreDesc(bank.getId()) - .forEach(e -> byBranch.computeIfAbsent(e.getBranchId(), k -> new ArrayList<>()).add(e)); + supervised.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())); @@ -218,7 +341,7 @@ public class ApexService { 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)); + Map row = new LinkedHashMap<>(cell("name", branch.getName(), book, tenant)); row.put("branchCode", branch.getCode()); row.put("region", branch.getRegion() == null ? "—" : branch.getRegion()); row.put("blocks", branch.getBlocks() == null ? "—" : branch.getBlocks()); @@ -231,33 +354,33 @@ public class ApexService { } /** Stress by district — the unit NABARD plans ground-level credit in. */ - public List> byDistrict() { + public List> byDistrict(TenantContext tenant) { Map names = new HashMap<>(); districtRepository.findAll().forEach(d -> names.put(d.getId(), d.getName())); Map> grouped = new LinkedHashMap<>(); - enterpriseRepository.findAll().forEach(e -> + inScope(tenant).forEach(e -> grouped.computeIfAbsent(e.getDistrictId(), k -> new ArrayList<>()).add(e)); List> out = new ArrayList<>(); grouped.forEach((districtId, list) -> out.add(cell( - "districtName", names.getOrDefault(districtId, "—"), list))); + "districtName", names.getOrDefault(districtId, "—"), list, tenant))); out.sort(Comparator.comparing(m -> String.valueOf(m.get("districtName")))); return out; } /** Stress by sector — the unit a shock actually arrives in. */ - public List> bySector() { + public List> bySector(TenantContext tenant) { Map names = new HashMap<>(); sectorRepository.findAll().forEach(s -> names.put(s.getId(), s.getLabel())); Map> grouped = new LinkedHashMap<>(); - enterpriseRepository.findAll().forEach(e -> + inScope(tenant).forEach(e -> grouped.computeIfAbsent(e.getSectorId(), k -> new ArrayList<>()).add(e)); List> out = new ArrayList<>(); grouped.forEach((sectorId, list) -> out.add(cell( - "sectorName", names.getOrDefault(sectorId, "—"), list))); + "sectorName", names.getOrDefault(sectorId, "—"), list, tenant))); out.sort(Comparator.comparing(m -> String.valueOf(m.get("sectorName")))); return out; } @@ -269,11 +392,11 @@ public class ApexService { * missing district reads as "no activity", which is a different and wrong * claim. */ - private Map cell(String labelKey, String label, List list) { + private Map cell(String labelKey, String label, List list, TenantContext tenant) { Map row = new LinkedHashMap<>(); row.put(labelKey, label); row.put("enterprises", list.size()); - if (list.size() < minCellSize) { + if (list.size() < minCellFor(tenant)) { row.put("suppressed", true); return row; } @@ -297,20 +420,20 @@ public class ApexService { * programme. Both are shown; the ordering says which NABARD should read * first. */ - public Map digest() { - List> districts = byDistrict().stream() + public Map digest(TenantContext tenant) { + List> districts = byDistrict(tenant).stream() .filter(m -> !Boolean.TRUE.equals(m.get("suppressed"))) .filter(m -> asLong(m.get("red")) > 0) .sorted((a, b) -> Long.compare(asLong(b.get("red")), asLong(a.get("red")))) .toList(); - List> sectors = bySector().stream() + List> sectors = bySector(tenant).stream() .filter(m -> !Boolean.TRUE.equals(m.get("suppressed"))) .filter(m -> asLong(m.get("red")) > 0) .sorted((a, b) -> Long.compare(asLong(b.get("red")), asLong(a.get("red")))) .toList(); - List> banks = bankScorecard().stream() + List> banks = bankScorecard(tenant).stream() .filter(m -> !Boolean.TRUE.equals(m.get("suppressed"))) .sorted((a, b) -> Double.compare(asDouble(a.get("responseRatePct")), asDouble(b.get("responseRatePct")))) .toList(); @@ -328,7 +451,7 @@ public class ApexService { // ── coverage of the institutional tier itself ─────────── /** Which banks and branches are on the platform, for the admin screen. */ - public List> institutions() { + public List> institutions(TenantContext tenant) { List> out = new ArrayList<>(); for (Institution institution : institutionRepository.findByActiveTrueOrderByName()) { List branches = branchRepository @@ -375,8 +498,8 @@ public class ApexService { } /** Days since the newest scoring run touched the book — the freshness of all of the above. */ - public long dataAgeDays() { - return enterpriseRepository.findAll().stream() + public long dataAgeDays(TenantContext tenant) { + return inScope(tenant).stream() .map(Enterprise::getLastScoredAt) .filter(Objects::nonNull) .max(Comparator.naturalOrder()) diff --git a/src/main/java/com/lincfusion/udyamlinc/apex/RegionalOfficeService.java b/src/main/java/com/lincfusion/udyamlinc/apex/RegionalOfficeService.java new file mode 100644 index 0000000..1e9837f --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/apex/RegionalOfficeService.java @@ -0,0 +1,237 @@ +package com.lincfusion.udyamlinc.apex; + +import com.lincfusion.udyamlinc.common.ResourceNotFoundException; +import com.lincfusion.udyamlinc.enterprise.Enterprise; +import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository; +import com.lincfusion.udyamlinc.institution.*; +import com.lincfusion.udyamlinc.master.District; +import com.lincfusion.udyamlinc.master.DistrictRepository; +import com.lincfusion.udyamlinc.security.TenantContext; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.*; + +/** + * The supervisory tier between head office and a partner bank. + * + *

Every rollup here resolves through {@code branch → district → state → + * office} rather than through anything stored on the bank. Six of the eight + * partner banks are RRBs and state-bound, so a bank-level office column would + * look right today and be wrong the moment the SFB opens a branch in a second + * state. + * + *

Nothing on this surface returns a borrower. An office is NABARD, and the + * reason the apex tier can be trusted with a national portfolio view at all is + * that it can never reach a customer — extending that view to a Regional Office + * must not quietly extend the entitlement with it. + */ +@Service +@RequiredArgsConstructor +public class RegionalOfficeService { + + private final RegionalOfficeRepository officeRepository; + private final RegionalOfficeStateRepository officeStateRepository; + private final InstitutionRepository institutionRepository; + private final BranchRepository branchRepository; + private final DistrictRepository districtRepository; + private final EnterpriseRepository enterpriseRepository; + + @Value("${udyam.apex.min-cell-size:5}") + private int minCellSize; + + @Value("${udyam.apex.min-cell-size.regional-office:5}") + private int minCellSizeRegional; + + /** The threshold that applies to this caller — see the note in the properties file. */ + private int minCellFor(TenantContext tenant) { + return tenant != null && tenant.isRegionalOffice() ? minCellSizeRegional : minCellSize; + } + + /** + * Offices visible to this caller: all of them for head office, exactly one + * for a Regional Office user. + * + * @param populatedOnly hide offices with no portfolio. Nineteen of the + * twenty-five are empty in the pilot, which is truthful + * but makes a screen look broken, so the console can ask + * for either. + */ + public List> offices(TenantContext tenant, boolean populatedOnly) { + Long scoped = tenant.regionalOfficeFilter(); + Context ctx = load(); + + List> out = new ArrayList<>(); + for (RegionalOffice office : ctx.offices) { + if (scoped != null && !office.getId().equals(scoped)) continue; + Map row = summarise(office, ctx, tenant); + if (populatedOnly && (int) row.get("enterprises") == 0) continue; + out.add(row); + } + out.sort((a, b) -> Integer.compare((int) b.get("enterprises"), (int) a.get("enterprises"))); + return out; + } + + /** + * The banks one office supervises, with the response-rate column an apex + * supervisor actually acts on. + * + *

An office the caller is not entitled to returns 404, identical to + * a code that does not exist. A 403 would confirm the office is real and + * simply not theirs, which is the same reasoning that governs cross-tenant + * borrower reads. + */ + public List> banks(TenantContext tenant, String officeCode) { + Context ctx = load(); + RegionalOffice office = ctx.offices.stream() + .filter(o -> o.getCode().equalsIgnoreCase(officeCode)) + .findFirst() + .orElseThrow(() -> new ResourceNotFoundException("No such regional office")); + + Long scoped = tenant.regionalOfficeFilter(); + if (scoped != null && !office.getId().equals(scoped)) { + throw new ResourceNotFoundException("No such regional office"); + } + + Set districts = districtIdsOf(office, ctx); + Map> byInstitution = new LinkedHashMap<>(); + + for (Branch branch : ctx.branches) { + if (!districts.contains(branch.getDistrictId())) continue; + byInstitution.computeIfAbsent(branch.getInstitutionId(), k -> new ArrayList<>()) + .addAll(ctx.enterprisesByBranch.getOrDefault(branch.getId(), List.of())); + } + + List> out = new ArrayList<>(); + byInstitution.forEach((institutionId, enterprises) -> { + Institution institution = ctx.institutionsById.get(institutionId); + if (institution == null) return; + Map row = new LinkedHashMap<>(); + row.put("institutionCode", institution.getCode()); + row.put("institutionName", institution.getName()); + row.put("type", institution.getType()); + row.putAll(bandCounts(enterprises, tenant)); + out.add(row); + }); + out.sort((a, b) -> Integer.compare((int) b.get("enterprises"), (int) a.get("enterprises"))); + return out; + } + + // ── internals ─────────────────────────────────────────── + + private Map summarise(RegionalOffice office, Context ctx, TenantContext tenant) { + Set districts = districtIdsOf(office, ctx); + List enterprises = new ArrayList<>(); + Set institutions = new LinkedHashSet<>(); + int branches = 0; + + for (Branch branch : ctx.branches) { + if (!districts.contains(branch.getDistrictId())) continue; + branches++; + institutions.add(branch.getInstitutionId()); + enterprises.addAll(ctx.enterprisesByBranch.getOrDefault(branch.getId(), List.of())); + } + + Map row = new LinkedHashMap<>(); + row.put("code", office.getCode()); + row.put("name", office.getName()); + row.put("nameHi", office.getNameHi()); + row.put("city", office.getCity()); + row.put("officeType", office.getOfficeType()); + row.put("states", ctx.statesByOffice.getOrDefault(office.getId(), List.of())); + row.put("banks", institutions.size()); + row.put("branches", branches); + row.putAll(bandCounts(enterprises, tenant)); + return row; + } + + /** + * Band split and portfolio value, suppressed below the minimum cell size. + * + *

Suppression bites harder here than at national level and that is not a + * defect: an office supervising one bank of twenty borrowers has genuinely + * small cells, and publishing them is how an aggregate stops protecting the + * borrower underneath it. + */ + private Map bandCounts(List enterprises, TenantContext tenant) { + Map row = new LinkedHashMap<>(); + int total = enterprises.size(); + row.put("enterprises", total); + + if (total > 0 && total < minCellFor(tenant)) { + row.put("suppressed", true); + row.put("red", null); + row.put("watch", null); + row.put("standard", null); + row.put("stressRatePct", null); + row.put("outstanding", null); + return row; + } + + // The stored band, not a band recomputed from the score. + // + // They are not the same number and must not be: a fired trip-wire + // escalates the band without rewriting the score, so Madhya Pradesh reads + // 35 RED by band and 5 RED by threshold. ApexService publishes the stored + // band, so recomputing here would make RED drop from 35 to 5 as a + // supervisor drilled from the national view into the office covering the + // very same state — the one place an inconsistency is guaranteed to be + // noticed, and the one place it destroys trust in both screens. + int red = 0, watch = 0, standard = 0; + BigDecimal outstanding = BigDecimal.ZERO; + for (Enterprise e : enterprises) { + String band = e.getBand() == null ? "STANDARD" : e.getBand(); + switch (band) { + case "RED" -> red++; + case "WATCH" -> watch++; + default -> standard++; + } + if (e.getLoanOutstanding() != null) outstanding = outstanding.add(e.getLoanOutstanding()); + } + + row.put("suppressed", false); + row.put("red", red); + row.put("watch", watch); + row.put("standard", standard); + row.put("stressRatePct", total == 0 ? 0.0 + : Math.round((red * 1000.0) / total) / 10.0); + row.put("outstanding", outstanding); + return row; + } + + private Set districtIdsOf(RegionalOffice office, Context ctx) { + List states = ctx.statesByOffice.getOrDefault(office.getId(), List.of()); + Set ids = new HashSet<>(); + for (District d : ctx.districts) { + if (states.contains(d.getState())) ids.add(d.getId()); + } + return ids; + } + + /** One pass over the masters. The whole book is 263 rows; this is not worth a query per office. */ + private Context load() { + Context ctx = new Context(); + ctx.offices = officeRepository.findByActiveTrueOrderByNameAsc(); + ctx.districts = districtRepository.findAll(); + ctx.branches = branchRepository.findAll(); + institutionRepository.findAll().forEach(i -> ctx.institutionsById.put(i.getId(), i)); + officeStateRepository.findAll().forEach(s -> ctx.statesByOffice + .computeIfAbsent(s.getRegionalOfficeId(), k -> new ArrayList<>()).add(s.getState())); + enterpriseRepository.findAll().forEach(e -> { + if (e.getBranchId() == null) return; + ctx.enterprisesByBranch.computeIfAbsent(e.getBranchId(), k -> new ArrayList<>()).add(e); + }); + return ctx; + } + + private static final class Context { + List offices = List.of(); + List districts = List.of(); + List branches = List.of(); + final Map institutionsById = new HashMap<>(); + final Map> statesByOffice = new HashMap<>(); + final Map> enterprisesByBranch = new HashMap<>(); + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/auth/AuthController.java b/src/main/java/com/lincfusion/udyamlinc/auth/AuthController.java index 219a398..4702372 100644 --- a/src/main/java/com/lincfusion/udyamlinc/auth/AuthController.java +++ b/src/main/java/com/lincfusion/udyamlinc/auth/AuthController.java @@ -9,6 +9,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.Map; /** The only unauthenticated surface. */ @@ -29,6 +30,24 @@ public class AuthController { return ApiResponse.ok("Signed in", authService.appLogin(request)); } + /** + * The Regional Offices, for the sign-in page's office picker. + * + *

Unauthenticated on purpose, and safe to be: NABARD publishes its office + * list, and this returns nothing but names and cities — no portfolio, no + * bank, no count. + * + *

Choosing an office here grants nothing. It fills in a username + * and no more. What a session may read is decided by the office claim inside + * the signed token, so a user who picks Bhopal and signs in with Bengaluru's + * credentials sees Bengaluru. An office the caller can select from a list is + * not a tenancy key. + */ + @GetMapping("/regional-offices") + public ApiResponse>> regionalOffices() { + return ApiResponse.ok(authService.regionalOfficeDirectory()); + } + /** Liveness probe, also used by the UI to decide whether to fall back to demo data. */ @GetMapping("/health") public ApiResponse> health() { diff --git a/src/main/java/com/lincfusion/udyamlinc/auth/AuthService.java b/src/main/java/com/lincfusion/udyamlinc/auth/AuthService.java index c7ffd26..39fd8f8 100644 --- a/src/main/java/com/lincfusion/udyamlinc/auth/AuthService.java +++ b/src/main/java/com/lincfusion/udyamlinc/auth/AuthService.java @@ -7,6 +7,8 @@ import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseSessionResponse; import com.lincfusion.udyamlinc.dto.UdyamResponses.OfficerSessionResponse; import com.lincfusion.udyamlinc.enterprise.Enterprise; import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository; +import com.lincfusion.udyamlinc.institution.RegionalOffice; +import com.lincfusion.udyamlinc.institution.RegionalOfficeRepository; import com.lincfusion.udyamlinc.master.Sector; import com.lincfusion.udyamlinc.master.SectorRepository; import com.lincfusion.udyamlinc.security.JwtUtil; @@ -17,8 +19,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * Sign-in for both surfaces: resolve the user, verify the secret, issue a token @@ -31,6 +36,7 @@ public class AuthService { private final UdyamUserRepository userRepository; private final EnterpriseRepository enterpriseRepository; private final SectorRepository sectorRepository; + private final RegionalOfficeRepository regionalOfficeRepository; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; @@ -43,6 +49,38 @@ public class AuthService { * pinned to their own. The distinction is made here, once, rather than being * re-derived by every endpoint. */ + /** + * Offices that have a sign-in, for the login page's picker. + * + *

Only offices with a user, because the picker exists to help someone sign + * in and an office nobody can sign in to is a dead entry. The username is + * included deliberately — these are seeded demo accounts on a prototype, and + * a reviewer walking the tiers should not need a credentials sheet. A real + * deployment drops this field and keeps the office list. + */ + @Transactional(readOnly = true) + public List> regionalOfficeDirectory() { + Map usernames = new LinkedHashMap<>(); + userRepository.findAll().forEach(u -> { + if (u.getRegionalOfficeId() != null && Boolean.TRUE.equals(u.getActive())) { + usernames.putIfAbsent(u.getRegionalOfficeId(), u.getUsername()); + } + }); + + List> out = new ArrayList<>(); + for (RegionalOffice office : regionalOfficeRepository.findByActiveTrueOrderByNameAsc()) { + String username = usernames.get(office.getId()); + if (username == null) continue; + Map row = new LinkedHashMap<>(); + row.put("code", office.getCode()); + row.put("name", office.getName()); + row.put("city", office.getCity()); + row.put("username", username); + out.add(row); + } + return out; + } + @Transactional public OfficerSessionResponse officerLogin(OfficerLoginRequest request) { UdyamUser user = userRepository.findByUsernameIgnoreCase(request.username().trim()) @@ -54,8 +92,12 @@ public class AuthService { verify(user, request.password()); touch(user); + // A Regional Office user is still a NABARD user; the office narrows what + // aggregates they see and changes nothing about what they may not see. + // Head office simply has no office claim. String token = "NABARD".equalsIgnoreCase(user.getScope()) - ? jwtUtil.generateApexToken(user.getUsername(), user.getRole(), user.getInstitutionId()) + ? jwtUtil.generateApexToken(user.getUsername(), user.getRole(), + user.getInstitutionId(), user.getRegionalOfficeId()) : jwtUtil.generateBankToken(user.getUsername(), user.getRole(), user.getBlocks(), user.getInstitutionId(), user.getBranchId()); List blocks = user.getBlocks() == null || user.getBlocks().isBlank() diff --git a/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUser.java b/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUser.java index e00dd75..f2618b1 100644 --- a/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUser.java +++ b/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUser.java @@ -63,6 +63,15 @@ public class UdyamUser { /** Set for branch-scoped users; null for a head-office or NABARD user. */ private Long branchId; + /** + * The NABARD Regional Office this user works in, or null for head office. + * + *

Only meaningful with {@code scope = NABARD}. A bank user carrying one + * would be meaningless — a bank is scoped by its institution, and an office + * is a supervisory boundary, not a lending one. + */ + private Long regionalOfficeId; + @Column(nullable = false) private Boolean active; diff --git a/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOffice.java b/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOffice.java new file mode 100644 index 0000000..721df5c --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOffice.java @@ -0,0 +1,69 @@ +package com.lincfusion.udyamlinc.institution; + +import jakarta.persistence.*; +import lombok.*; + +/** + * A NABARD Regional Office — the supervisory tier between the apex and a partner + * bank. + * + *

An RO is NABARD, not a lender and not a super-user over its banks. It + * sees the banks in its states, their branches and their aggregates, and it may + * read no borrower row at all. That restriction is inherited rather than + * re-implemented: an RO session carries {@code Tier.APEX}, so + * {@code TenantContext.mayReadBorrowers()} is already false for it, and the only + * thing the RO tier adds is a geographic narrowing of what aggregates it may see. + * + *

Getting that inheritance right is the point. If an RO could open an + * enterprise, the sentence the whole platform rests on — because the + * supervisory tiers can never reach a borrower, portfolio-wide early warning can + * be granted to them at all — would stop being true, and a partner bank's + * reason for connecting its book would go with it. + * + *

The office is located in a city and named for the state it supervises, and + * those are different facts: NABARD's Head Office and its Maharashtra Regional + * Office are both in Mumbai. A screen that shows only the city makes the + * drill-down read as circular. + */ +@Entity +@Table(name = "regional_office", indexes = { + @Index(name = "ix_ro_code", columnList = "code", unique = true), +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RegionalOffice { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** BHOPAL, GUWAHATI, CHANDIGARH… */ + @Column(nullable = false, unique = true, length = 32) + private String code; + + /** "Madhya Pradesh Regional Office" — how NABARD refers to it. */ + @Column(nullable = false, length = 96) + private String name; + + @Column(length = 96) + private String nameHi; + + /** Where it sits. Not the same as what it covers, and not unique. */ + @Column(nullable = false, length = 64) + private String city; + + /** REGIONAL_OFFICE | LOCAL_OFFICE — Uttar Pradesh is a Local Office. */ + @Column(length = 24) + private String officeType; + + private Boolean active; + + @PrePersist + void onCreate() { + if (active == null) active = Boolean.TRUE; + if (officeType == null) officeType = "REGIONAL_OFFICE"; + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOfficeRepository.java b/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOfficeRepository.java new file mode 100644 index 0000000..e4690ec --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOfficeRepository.java @@ -0,0 +1,13 @@ +package com.lincfusion.udyamlinc.institution; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface RegionalOfficeRepository extends JpaRepository { + + Optional findByCode(String code); + + List findByActiveTrueOrderByNameAsc(); +} diff --git a/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOfficeState.java b/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOfficeState.java new file mode 100644 index 0000000..de06a89 --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOfficeState.java @@ -0,0 +1,50 @@ +package com.lincfusion.udyamlinc.institution; + +import jakarta.persistence.*; +import lombok.*; + +/** + * Which states an office supervises. + * + *

A separate table rather than a column on {@link RegionalOffice}, because the + * relationship really is one-to-many: Chandigarh covers Punjab and Haryana, and + * Mumbai covers Maharashtra and Goa. + * + *

The reverse direction is what makes the tenancy work — a state belongs to + * exactly one office. That single-valuedness is why an RO can be resolved + * deterministically as {@code branch → district → state → office}, with no + * ambiguity to arbitrate and nothing to store on the bank. + * + *

Which matters more than it sounds. Six of the eight partner banks are RRBs + * and state-bound by charter, so a bank-level office field would look correct + * today; Vindhya SFB is nationally licensed, and the day it opens a branch + * outside Madhya Pradesh that field becomes quietly wrong. Derived from the + * branch, a bank simply belongs to every office its branches sit in. + */ +@Entity +@Table(name = "regional_office_state", indexes = { + @Index(name = "ix_ros_office", columnList = "regionalOfficeId"), + @Index(name = "ix_ros_state", columnList = "state", unique = true), +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RegionalOfficeState { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long regionalOfficeId; + + /** + * Matches {@code district.state} exactly. The unique index is deliberate: two + * offices claiming one state would make the drill-down non-deterministic and + * double-count that state in every national rollup. + */ + @Column(nullable = false, length = 64) + private String state; +} diff --git a/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOfficeStateRepository.java b/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOfficeStateRepository.java new file mode 100644 index 0000000..502aee1 --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/institution/RegionalOfficeStateRepository.java @@ -0,0 +1,13 @@ +package com.lincfusion.udyamlinc.institution; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface RegionalOfficeStateRepository extends JpaRepository { + + Optional findByState(String state); + + List findByRegionalOfficeId(Long regionalOfficeId); +} diff --git a/src/main/java/com/lincfusion/udyamlinc/security/JwtAuthFilter.java b/src/main/java/com/lincfusion/udyamlinc/security/JwtAuthFilter.java index 534a345..36e8efa 100644 --- a/src/main/java/com/lincfusion/udyamlinc/security/JwtAuthFilter.java +++ b/src/main/java/com/lincfusion/udyamlinc/security/JwtAuthFilter.java @@ -75,6 +75,10 @@ public class JwtAuthFilter extends OncePerRequestFilter { role, jwtUtil.institutionId(token), jwtUtil.branchId(token), - jwtUtil.enterpriseId(token)); + jwtUtil.enterpriseId(token), + // Signed into the token like every other tenancy key, never read + // from the request. A Regional Office a caller can name is not a + // Regional Office. + jwtUtil.regionalOfficeId(token)); } } diff --git a/src/main/java/com/lincfusion/udyamlinc/security/JwtUtil.java b/src/main/java/com/lincfusion/udyamlinc/security/JwtUtil.java index 43ac34d..5c95d8e 100644 --- a/src/main/java/com/lincfusion/udyamlinc/security/JwtUtil.java +++ b/src/main/java/com/lincfusion/udyamlinc/security/JwtUtil.java @@ -54,11 +54,27 @@ public class JwtUtil { * claim is what {@code TenantContext} reads to mean exactly that. */ public String generateApexToken(String username, String role, Long institutionId) { + return generateApexToken(username, role, institutionId, null); + } + + /** + * NABARD session narrowed to one Regional Office. + * + *

Still {@code scope=NABARD}, because an RO is the apex tier — + * it inherits the rule that no borrower row may be read and adds only a + * geographic restriction on the aggregates. Issuing it as its own scope would + * mean re-deciding that rule for a second tier, which is exactly how a tier + * eventually acquires a permission nobody meant to grant it. + * + *

A null office is head office: apex over the whole country. + */ + public String generateApexToken(String username, String role, Long institutionId, Long regionalOfficeId) { Map claims = new HashMap<>(); claims.put("realm", "UDYAM"); claims.put("scope", "NABARD"); claims.put("role", role); claims.put("institutionId", institutionId); + if (regionalOfficeId != null) claims.put("regionalOfficeId", regionalOfficeId); return build(claims, username); } @@ -103,6 +119,11 @@ public class JwtUtil { return String.valueOf(parse(token).get("role")); } + /** Null for head office and for every non-apex session. */ + public Long regionalOfficeId(String token) { + return longClaim(parse(token), "regionalOfficeId"); + } + public Long institutionId(String token) { return longClaim(parse(token), "institutionId"); } diff --git a/src/main/java/com/lincfusion/udyamlinc/security/TenantContext.java b/src/main/java/com/lincfusion/udyamlinc/security/TenantContext.java index 262899f..c2a3b75 100644 --- a/src/main/java/com/lincfusion/udyamlinc/security/TenantContext.java +++ b/src/main/java/com/lincfusion/udyamlinc/security/TenantContext.java @@ -41,10 +41,43 @@ public class TenantContext { /** Set only for an ENTERPRISE session. */ private final String enterpriseId; + /** + * Set only for a NABARD Regional Office session; null for head office. + * + *

An RO is a narrowing of APEX, not a tier of its own. It carries + * {@code Tier.APEX} precisely so that {@link #mayReadBorrowers()} is already + * false for it — the rule that the supervisory tiers can never reach a + * borrower is inherited here rather than restated, because a rule written in + * two places is one that eventually disagrees with itself. + */ + private final Long regionalOfficeId; + public boolean isApex() { return tier == Tier.APEX; } + /** An apex session pinned to one Regional Office rather than all-India. */ + public boolean isRegionalOffice() { + return tier == Tier.APEX && regionalOfficeId != null; + } + + /** True for NABARD head office — apex with no regional narrowing. */ + public boolean isHeadOffice() { + return tier == Tier.APEX && regionalOfficeId == null; + } + + /** + * The Regional Office an apex query must be narrowed to, or null when the + * caller is head office and entitled to the whole country. + * + *

Returns null for a bank session too: a bank is scoped by + * {@link #portfolioFilter()} and applying both would be a second, redundant + * filter that could only ever narrow a bank's view of its own book. + */ + public Long regionalOfficeFilter() { + return tier == Tier.APEX ? regionalOfficeId : null; + } + public boolean isBank() { return tier == Tier.BANK; } diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/RegionalOfficeSeeder.java b/src/main/java/com/lincfusion/udyamlinc/seed/RegionalOfficeSeeder.java new file mode 100644 index 0000000..bb41927 --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/seed/RegionalOfficeSeeder.java @@ -0,0 +1,173 @@ +package com.lincfusion.udyamlinc.seed; + +import com.lincfusion.udyamlinc.auth.UdyamUser; +import com.lincfusion.udyamlinc.auth.UdyamUserRepository; +import com.lincfusion.udyamlinc.institution.*; +import com.lincfusion.udyamlinc.master.DistrictRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** + * NABARD's Regional Offices and the states each supervises. + * + *

Twenty-five offices covering twenty-six states. Two of them supervise more + * than one state — Chandigarh takes Punjab and Haryana, Mumbai takes Maharashtra + * and Goa — which is the whole reason the mapping is a table rather than a column. + * + *

Only six offices have a portfolio in the pilot: Bhopal, Guwahati, Patna, + * Mumbai, Bengaluru and Jaipur. The other nineteen are seeded anyway. An empty + * office is a true statement — NABARD supervises that state and this pilot has no + * book there yet — whereas a missing office would make the national structure + * look like it stops at the pilot's edge, and would strand any bank later opened + * outside those six states. + * + *

Upserted per code, never guarded by {@code count() > 0}: the guard is why + * anything added after a database was first populated silently never arrives. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class RegionalOfficeSeeder { + + private final RegionalOfficeRepository officeRepository; + private final RegionalOfficeStateRepository officeStateRepository; + private final InstitutionRepository institutionRepository; + private final DistrictRepository districtRepository; + private final UdyamUserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + /** code, display name, city, office type, and the states it covers. */ + private record OfficeSpec(String code, String name, String nameHi, String city, + String officeType, List states) { + } + + private static OfficeSpec ro(String code, String name, String nameHi, String city, String... states) { + return new OfficeSpec(code, name, nameHi, city, "REGIONAL_OFFICE", List.of(states)); + } + + private static final List OFFICES = List.of( + // Andhra Pradesh is listed as "Amaravati / Hyderabad" in the source + // table while Telangana maps to Hyderabad. Split here rather than + // merged: one office covering both would silently roll two states' + // books together, and unpicking that later is worse than being wrong + // about a building. + ro("AMARAVATI", "Andhra Pradesh Regional Office", "आंध्र प्रदेश क्षेत्रीय कार्यालय", "Amaravati", "Andhra Pradesh"), + ro("GUWAHATI", "Assam Regional Office", "असम क्षेत्रीय कार्यालय", "Guwahati", "Assam"), + ro("PATNA", "Bihar Regional Office", "बिहार क्षेत्रीय कार्यालय", "Patna", "Bihar"), + ro("RAIPUR", "Chhattisgarh Regional Office", "छत्तीसगढ़ क्षेत्रीय कार्यालय", "Raipur", "Chhattisgarh"), + ro("AHMEDABAD", "Gujarat Regional Office", "गुजरात क्षेत्रीय कार्यालय", "Ahmedabad", "Gujarat"), + // One office, two states. + ro("CHANDIGARH", "Punjab & Haryana Regional Office", "पंजाब एवं हरियाणा क्षेत्रीय कार्यालय", "Chandigarh", "Punjab", "Haryana"), + ro("SHIMLA", "Himachal Pradesh Regional Office", "हिमाचल प्रदेश क्षेत्रीय कार्यालय", "Shimla", "Himachal Pradesh"), + ro("RANCHI", "Jharkhand Regional Office", "झारखंड क्षेत्रीय कार्यालय", "Ranchi", "Jharkhand"), + ro("BENGALURU", "Karnataka Regional Office", "कर्नाटक क्षेत्रीय कार्यालय", "Bengaluru", "Karnataka"), + ro("THIRUVANANTHAPURAM", "Kerala Regional Office", "केरल क्षेत्रीय कार्यालय", "Thiruvananthapuram", "Kerala"), + ro("BHOPAL", "Madhya Pradesh Regional Office", "मध्य प्रदेश क्षेत्रीय कार्यालय", "Bhopal", "Madhya Pradesh"), + // Shares a city with Head Office, which is why the UI must show the + // office name and not just "Mumbai". + ro("MUMBAI", "Maharashtra Regional Office", "महाराष्ट्र क्षेत्रीय कार्यालय", "Mumbai", "Maharashtra", "Goa"), + ro("IMPHAL", "Manipur Regional Office", "मणिपुर क्षेत्रीय कार्यालय", "Imphal", "Manipur"), + ro("SHILLONG", "Meghalaya Regional Office", "मेघालय क्षेत्रीय कार्यालय", "Shillong", "Meghalaya"), + ro("AIZAWL", "Mizoram Regional Office", "मिज़ोरम क्षेत्रीय कार्यालय", "Aizawl", "Mizoram"), + ro("KOHIMA", "Nagaland Regional Office", "नागालैंड क्षेत्रीय कार्यालय", "Kohima", "Nagaland"), + ro("BHUBANESWAR", "Odisha Regional Office", "ओडिशा क्षेत्रीय कार्यालय", "Bhubaneswar", "Odisha"), + ro("JAIPUR", "Rajasthan Regional Office", "राजस्थान क्षेत्रीय कार्यालय", "Jaipur", "Rajasthan"), + ro("GANGTOK", "Sikkim Regional Office", "सिक्किम क्षेत्रीय कार्यालय", "Gangtok", "Sikkim"), + ro("CHENNAI", "Tamil Nadu Regional Office", "तमिलनाडु क्षेत्रीय कार्यालय", "Chennai", "Tamil Nadu"), + ro("HYDERABAD", "Telangana Regional Office", "तेलंगाना क्षेत्रीय कार्यालय", "Hyderabad", "Telangana"), + ro("AGARTALA", "Tripura Regional Office", "त्रिपुरा क्षेत्रीय कार्यालय", "Agartala", "Tripura"), + // A Local Office in NABARD's own structure, not a Regional Office. + new OfficeSpec("LUCKNOW", "Uttar Pradesh Local Office", "उत्तर प्रदेश स्थानीय कार्यालय", + "Lucknow", "LOCAL_OFFICE", List.of("Uttar Pradesh")), + ro("DEHRADUN", "Uttarakhand Regional Office", "उत्तराखंड क्षेत्रीय कार्यालय", "Dehradun", "Uttarakhand"), + ro("KOLKATA", "West Bengal Regional Office", "पश्चिम बंगाल क्षेत्रीय कार्यालय", "Kolkata", "West Bengal")); + + /** + * One RO login per office that actually supervises a portfolio. + * + *

Only the six with a book, because a login to an office with nothing in + * it demonstrates nothing and adds five empty consoles to the demo. The other + * nineteen offices still exist as data — they just have nobody to sign in as + * until a bank opens there. + */ + private void seedOfficeUsers() { + int written = 0; + for (RegionalOffice office : officeRepository.findByActiveTrueOrderByNameAsc()) { + // Skip offices with no supervised branches — resolved through the + // state mapping, the same path the tenancy filter uses. + boolean hasPortfolio = officeStateRepository.findByRegionalOfficeId(office.getId()).stream() + .anyMatch(s -> districtRepository.findByActiveTrue().stream() + .anyMatch(d -> d.getState().equals(s.getState()))); + if (!hasPortfolio) continue; + + String username = office.getCity().toLowerCase().replace(" ", "") + ".ro"; + if (userRepository.findByUsernameIgnoreCase(username).isPresent()) continue; + + userRepository.save(UdyamUser.builder() + .username(username) + .fullName(office.getName()) + .password(passwordEncoder.encode(UdyamDataSeeder.DEFAULT_OFFICER_PASSWORD)) + .role("RO_ADMIN") + // NABARD scope, not a scope of its own — the office is a + // narrowing of the apex tier, and the no-borrower rule comes + // with it rather than being granted again. + .scope("NABARD") + .institutionId(apexInstitutionId()) + .regionalOfficeId(office.getId()) + .branch(office.getCity()) + .active(true) + .build()); + written++; + } + if (written > 0) log.info("Seeded {} regional-office logins (password {})", + written, UdyamDataSeeder.DEFAULT_OFFICER_PASSWORD); + } + + private Long apexInstitutionId() { + return institutionRepository.findByCode("NABARD").map(Institution::getId).orElse(null); + } + + public void seed() { + int offices = 0, mappings = 0; + + for (OfficeSpec spec : OFFICES) { + Optional existing = officeRepository.findByCode(spec.code()); + RegionalOffice office = existing.orElse(null); + if (office == null) { + office = officeRepository.save(RegionalOffice.builder() + .code(spec.code()).name(spec.name()).nameHi(spec.nameHi()) + .city(spec.city()).officeType(spec.officeType()).active(true) + .build()); + offices++; + } + + for (String state : spec.states()) { + Optional mapped = officeStateRepository.findByState(state); + if (mapped.isPresent()) { + // A state already claimed by a different office is a genuine + // conflict, not something to silently reassign — the unique + // index on state exists to make it impossible, and this says + // so out loud if the plan ever contradicts itself. + if (!mapped.get().getRegionalOfficeId().equals(office.getId())) { + log.warn("State {} is already mapped to office id {} — not reassigning to {}", + state, mapped.get().getRegionalOfficeId(), spec.code()); + } + continue; + } + officeStateRepository.save(RegionalOfficeState.builder() + .regionalOfficeId(office.getId()).state(state).build()); + mappings++; + } + } + + if (offices > 0) log.info("Seeded {} NABARD regional offices", offices); + if (mappings > 0) log.info("Seeded {} office-state mappings", mappings); + + seedOfficeUsers(); + } +}