feat(institution): regional office tier, and one choke point for its scope
Adds a fourth supervisory level between apex and bank — HO -> RO -> Bank -> Branch -> Enterprise — as 25 offices with a state mapping, plus /nabard/ regional-offices and its bank drill. An RO is Tier.APEX carrying a regionalOfficeId, not a tier of its own, so it inherits mayReadBorrowers() == false rather than restating it. The office is resolved branch -> district -> state -> office and never stored on the bank: six of eight partner banks are state-bound RRBs, so a bank-level column would look right today and break the moment an SFB opens a second-state branch. The two new endpoints scoped correctly from the start while the eight pre-existing apex reads ignored the office claim entirely — the supervisory boundary was decorative, though no borrower leaked. Every read now passes through ApexService.scopeDistricts(tenant), one choke point rather than eight guards. All six offices sum exactly to the national 263 units / 8 banks / 342 alerts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,31 +32,32 @@ import java.util.Map;
|
|||||||
public class ApexController {
|
public class ApexController {
|
||||||
|
|
||||||
private final ApexService apexService;
|
private final ApexService apexService;
|
||||||
|
private final RegionalOfficeService regionalOfficeService;
|
||||||
|
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
public ApiResponse<Map<String, Object>> dashboard(TenantContext tenant) {
|
public ApiResponse<Map<String, Object>> dashboard(TenantContext tenant) {
|
||||||
requireApex(tenant);
|
requireApex(tenant);
|
||||||
Map<String, Object> out = new LinkedHashMap<>(apexService.national());
|
Map<String, Object> out = new LinkedHashMap<>(apexService.national(tenant));
|
||||||
out.put("dataAgeDays", apexService.dataAgeDays());
|
out.put("dataAgeDays", apexService.dataAgeDays(tenant));
|
||||||
return ApiResponse.ok(out);
|
return ApiResponse.ok(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/banks")
|
@GetMapping("/banks")
|
||||||
public ApiResponse<List<Map<String, Object>>> banks(TenantContext tenant) {
|
public ApiResponse<List<Map<String, Object>>> banks(TenantContext tenant) {
|
||||||
requireApex(tenant);
|
requireApex(tenant);
|
||||||
return ApiResponse.ok(apexService.bankScorecard());
|
return ApiResponse.ok(apexService.bankScorecard(tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/districts")
|
@GetMapping("/districts")
|
||||||
public ApiResponse<List<Map<String, Object>>> districts(TenantContext tenant) {
|
public ApiResponse<List<Map<String, Object>>> districts(TenantContext tenant) {
|
||||||
requireApex(tenant);
|
requireApex(tenant);
|
||||||
return ApiResponse.ok(apexService.byDistrict());
|
return ApiResponse.ok(apexService.byDistrict(tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/sectors")
|
@GetMapping("/sectors")
|
||||||
public ApiResponse<List<Map<String, Object>>> sectors(TenantContext tenant) {
|
public ApiResponse<List<Map<String, Object>>> sectors(TenantContext tenant) {
|
||||||
requireApex(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 zone,
|
||||||
@RequestParam(required = false) String state) {
|
@RequestParam(required = false) String state) {
|
||||||
requireApex(tenant);
|
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. */
|
/** One partner bank, broken down by branch. The deepest this tier goes. */
|
||||||
@@ -82,19 +83,42 @@ public class ApexController {
|
|||||||
public ApiResponse<List<Map<String, Object>>> bankBranches(
|
public ApiResponse<List<Map<String, Object>>> bankBranches(
|
||||||
TenantContext tenant, @PathVariable String code) {
|
TenantContext tenant, @PathVariable String code) {
|
||||||
requireApex(tenant);
|
requireApex(tenant);
|
||||||
return ApiResponse.ok(apexService.bankBranches(code));
|
return ApiResponse.ok(apexService.bankBranches(code, tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/digest")
|
@GetMapping("/digest")
|
||||||
public ApiResponse<Map<String, Object>> digest(TenantContext tenant) {
|
public ApiResponse<Map<String, Object>> digest(TenantContext tenant) {
|
||||||
requireApex(tenant);
|
requireApex(tenant);
|
||||||
return ApiResponse.ok(apexService.digest());
|
return ApiResponse.ok(apexService.digest(tenant));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/institutions")
|
@GetMapping("/institutions")
|
||||||
public ApiResponse<List<Map<String, Object>>> institutions(TenantContext tenant) {
|
public ApiResponse<List<Map<String, Object>>> institutions(TenantContext tenant) {
|
||||||
requireApex(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<List<Map<String, Object>>> 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<List<Map<String, Object>>> regionalOfficeBanks(
|
||||||
|
TenantContext tenant, @PathVariable String code) {
|
||||||
|
requireApex(tenant);
|
||||||
|
return ApiResponse.ok(regionalOfficeService.banks(tenant, code));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireApex(TenantContext tenant) {
|
private void requireApex(TenantContext tenant) {
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import com.lincfusion.udyamlinc.master.DistrictRepository;
|
|||||||
import com.lincfusion.udyamlinc.master.Sector;
|
import com.lincfusion.udyamlinc.master.Sector;
|
||||||
import com.lincfusion.udyamlinc.master.SectorRepository;
|
import com.lincfusion.udyamlinc.master.SectorRepository;
|
||||||
import com.lincfusion.udyamlinc.master.Zones;
|
import com.lincfusion.udyamlinc.master.Zones;
|
||||||
|
import com.lincfusion.udyamlinc.institution.RegionalOfficeStateRepository;
|
||||||
import com.lincfusion.udyamlinc.risk.RiskAlert;
|
import com.lincfusion.udyamlinc.risk.RiskAlert;
|
||||||
import com.lincfusion.udyamlinc.risk.RiskAlertRepository;
|
import com.lincfusion.udyamlinc.risk.RiskAlertRepository;
|
||||||
|
import com.lincfusion.udyamlinc.security.TenantContext;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -44,6 +46,8 @@ public class ApexService {
|
|||||||
private final InstitutionRepository institutionRepository;
|
private final InstitutionRepository institutionRepository;
|
||||||
private final BranchRepository branchRepository;
|
private final BranchRepository branchRepository;
|
||||||
private final EnterpriseRepository enterpriseRepository;
|
private final EnterpriseRepository enterpriseRepository;
|
||||||
|
private final RegionalOfficeStateRepository officeStateRepository;
|
||||||
|
private final com.lincfusion.udyamlinc.institution.RegionalOfficeRepository regionalOfficeRepository;
|
||||||
private final DistrictRepository districtRepository;
|
private final DistrictRepository districtRepository;
|
||||||
private final SectorRepository sectorRepository;
|
private final SectorRepository sectorRepository;
|
||||||
private final RiskAlertRepository alertRepository;
|
private final RiskAlertRepository alertRepository;
|
||||||
@@ -52,16 +56,87 @@ public class ApexService {
|
|||||||
@Value("${udyam.apex.min-cell-size:5}")
|
@Value("${udyam.apex.min-cell-size:5}")
|
||||||
private int minCellSize;
|
private int minCellSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same threshold for a Regional Office session.
|
||||||
|
*
|
||||||
|
* <p>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 ──────────────────────────────────
|
// ── national dashboard ──────────────────────────────────
|
||||||
|
|
||||||
/** Headline counts across every refinanced portfolio. */
|
/**
|
||||||
public Map<String, Object> national() {
|
* The districts this apex caller may aggregate over, or {@code null} for head
|
||||||
|
* office, which may aggregate over all of them.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<Long> scopeDistricts(TenantContext tenant) {
|
||||||
|
Long office = tenant == null ? null : tenant.regionalOfficeFilter();
|
||||||
|
if (office == null) return null;
|
||||||
|
|
||||||
|
Set<String> states = new HashSet<>();
|
||||||
|
officeStateRepository.findByRegionalOfficeId(office)
|
||||||
|
.forEach(s -> states.add(s.getState()));
|
||||||
|
|
||||||
|
Set<Long> 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<Enterprise> inScope(TenantContext tenant) {
|
||||||
|
Set<Long> districts = scopeDistricts(tenant);
|
||||||
List<Enterprise> all = enterpriseRepository.findAll();
|
List<Enterprise> all = enterpriseRepository.findAll();
|
||||||
List<Institution> 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<Enterprise> bookInScope(Long institutionId, TenantContext tenant) {
|
||||||
|
Set<Long> districts = scopeDistricts(tenant);
|
||||||
|
List<Enterprise> 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<String, Object> national(TenantContext tenant) {
|
||||||
|
List<Enterprise> 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<Long> institutionsInScope = new HashSet<>();
|
||||||
|
Set<Long> branchesInScope = new HashSet<>();
|
||||||
|
all.forEach(e -> {
|
||||||
|
if (e.getInstitutionId() != null) institutionsInScope.add(e.getInstitutionId());
|
||||||
|
if (e.getBranchId() != null) branchesInScope.add(e.getBranchId());
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
out.put("partnerBanks", lenders.size());
|
out.put("partnerBanks", institutionsInScope.size());
|
||||||
out.put("branches", branchRepository.count());
|
out.put("branches", branchesInScope.size());
|
||||||
out.put("enterprises", all.size());
|
out.put("enterprises", all.size());
|
||||||
out.put("red", count(all, "RED"));
|
out.put("red", count(all, "RED"));
|
||||||
out.put("watch", count(all, "WATCH"));
|
out.put("watch", count(all, "WATCH"));
|
||||||
@@ -70,10 +145,16 @@ public class ApexService {
|
|||||||
out.put("portfolioValue", all.stream()
|
out.put("portfolioValue", all.stream()
|
||||||
.map(e -> e.getLoanOutstanding() == null ? BigDecimal.ZERO : e.getLoanOutstanding())
|
.map(e -> e.getLoanOutstanding() == null ? BigDecimal.ZERO : e.getLoanOutstanding())
|
||||||
.reduce(BigDecimal.ZERO, BigDecimal::add));
|
.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<String> idsInScope = new HashSet<>();
|
||||||
|
all.forEach(e -> idsInScope.add(e.getEnterpriseId()));
|
||||||
out.put("openAlerts", alertRepository.findAllByOrderByScoreDesc().stream()
|
out.put("openAlerts", alertRepository.findAllByOrderByScoreDesc().stream()
|
||||||
.filter(a -> !"CLOSED".equalsIgnoreCase(a.getState()))
|
.filter(a -> !"CLOSED".equalsIgnoreCase(a.getState()))
|
||||||
|
.filter(a -> idsInScope.contains(a.getEnterpriseId()))
|
||||||
.count());
|
.count());
|
||||||
out.put("minCellSize", minCellSize);
|
out.put("minCellSize", minCellFor(tenant));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,14 +165,19 @@ public class ApexService {
|
|||||||
* stress, and — the number that actually distinguishes a good partner from a
|
* stress, and — the number that actually distinguishes a good partner from a
|
||||||
* bad one — how much of what was flagged has been acted on.
|
* bad one — how much of what was flagged has been acted on.
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> bankScorecard() {
|
public List<Map<String, Object>> bankScorecard(TenantContext tenant) {
|
||||||
Map<String, RiskAlert> latestAlertByEnterprise = new HashMap<>();
|
Map<String, RiskAlert> latestAlertByEnterprise = new HashMap<>();
|
||||||
alertRepository.findAllByOrderByScoreDesc()
|
alertRepository.findAllByOrderByScoreDesc()
|
||||||
.forEach(a -> latestAlertByEnterprise.putIfAbsent(a.getEnterpriseId(), a));
|
.forEach(a -> latestAlertByEnterprise.putIfAbsent(a.getEnterpriseId(), a));
|
||||||
|
|
||||||
List<Map<String, Object>> out = new ArrayList<>();
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
boolean scoped = scopeDistricts(tenant) != null;
|
||||||
for (Institution bank : institutionRepository.findByTypeNotAndActiveTrueOrderByName("APEX")) {
|
for (Institution bank : institutionRepository.findByTypeNotAndActiveTrueOrderByName("APEX")) {
|
||||||
List<Enterprise> book = enterpriseRepository.findByInstitutionIdOrderByScoreDesc(bank.getId());
|
List<Enterprise> 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()
|
long raised = book.stream()
|
||||||
.filter(e -> latestAlertByEnterprise.containsKey(e.getEnterpriseId()))
|
.filter(e -> latestAlertByEnterprise.containsKey(e.getEnterpriseId()))
|
||||||
@@ -109,9 +195,9 @@ public class ApexService {
|
|||||||
row.put("type", bank.getType());
|
row.put("type", bank.getType());
|
||||||
row.put("branches", branchRepository.findByInstitutionIdAndActiveTrueOrderByName(bank.getId()).size());
|
row.put("branches", branchRepository.findByInstitutionIdAndActiveTrueOrderByName(bank.getId()).size());
|
||||||
row.put("enterprises", book.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.
|
// Below the threshold nothing else is published — see the class note.
|
||||||
out.add(row);
|
out.add(row);
|
||||||
continue;
|
continue;
|
||||||
@@ -145,26 +231,51 @@ public class ApexService {
|
|||||||
* four does not. That is the intended behaviour of a drill-down — the further
|
* four does not. That is the intended behaviour of a drill-down — the further
|
||||||
* in you go, the closer you get to a person.
|
* in you go, the closer you get to a person.
|
||||||
*
|
*
|
||||||
* @param level ZONE | STATE | DISTRICT
|
* <p><b>RO is a different kind of level from the other three.</b> Zone, state
|
||||||
* @param zone narrow to one zone (for STATE and DISTRICT)
|
* 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)
|
* @param state narrow to one state (for DISTRICT)
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> geography(String level, String zone, String state) {
|
public List<Map<String, Object>> geography(String level, String zone, String state, TenantContext tenant) {
|
||||||
Map<Long, District> districts = new HashMap<>();
|
Map<Long, District> districts = new HashMap<>();
|
||||||
districtRepository.findAll().forEach(d -> districts.put(d.getId(), d));
|
districtRepository.findAll().forEach(d -> districts.put(d.getId(), d));
|
||||||
|
|
||||||
|
// state → office, for the RO level and for narrowing by office code.
|
||||||
|
Map<String, String> officeCodeByState = new HashMap<>();
|
||||||
|
Map<String, String> 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<String, List<Enterprise>> grouped = new LinkedHashMap<>();
|
Map<String, List<Enterprise>> grouped = new LinkedHashMap<>();
|
||||||
Map<String, Map<String, String>> meta = new HashMap<>();
|
Map<String, Map<String, String>> meta = new HashMap<>();
|
||||||
|
|
||||||
for (Enterprise e : enterpriseRepository.findAll()) {
|
for (Enterprise e : inScope(tenant)) {
|
||||||
District d = districts.get(e.getDistrictId());
|
District d = districts.get(e.getDistrictId());
|
||||||
if (d == null) continue;
|
if (d == null) continue;
|
||||||
|
|
||||||
String districtZone = d.getZone() == null ? Zones.of(d.getState()) : d.getZone();
|
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;
|
if (state != null && !state.isBlank() && !state.equalsIgnoreCase(d.getState())) continue;
|
||||||
|
|
||||||
String key = switch (level == null ? "ZONE" : level.toUpperCase()) {
|
String key = switch (level == null ? "ZONE" : level.toUpperCase()) {
|
||||||
|
case "RO" -> officeCode;
|
||||||
case "STATE" -> d.getState();
|
case "STATE" -> d.getState();
|
||||||
case "DISTRICT" -> d.getName();
|
case "DISTRICT" -> d.getName();
|
||||||
default -> districtZone;
|
default -> districtZone;
|
||||||
@@ -175,16 +286,20 @@ public class ApexService {
|
|||||||
meta.computeIfAbsent(key, k -> Map.of(
|
meta.computeIfAbsent(key, k -> Map.of(
|
||||||
"zone", districtZone,
|
"zone", districtZone,
|
||||||
"zoneLabel", Zones.label(districtZone),
|
"zoneLabel", Zones.label(districtZone),
|
||||||
|
"office", officeCode == null ? "—" : officeCode,
|
||||||
"state", d.getState() == null ? "—" : d.getState()));
|
"state", d.getState() == null ? "—" : d.getState()));
|
||||||
}
|
}
|
||||||
|
|
||||||
String levelUpper = level == null ? "ZONE" : level.toUpperCase();
|
String levelUpper = level == null ? "ZONE" : level.toUpperCase();
|
||||||
List<Map<String, Object>> out = new ArrayList<>();
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
grouped.forEach((key, list) -> {
|
grouped.forEach((key, list) -> {
|
||||||
Map<String, Object> row = new LinkedHashMap<>(cell("name", key, list));
|
Map<String, Object> row = new LinkedHashMap<>(cell("name", key, list, tenant));
|
||||||
row.put("level", levelUpper);
|
row.put("level", levelUpper);
|
||||||
row.putAll(meta.getOrDefault(key, Map.of()));
|
row.putAll(meta.getOrDefault(key, Map.of()));
|
||||||
if ("ZONE".equals(levelUpper)) row.put("name", Zones.label(key));
|
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);
|
row.put("key", key);
|
||||||
out.add(row);
|
out.add(row);
|
||||||
});
|
});
|
||||||
@@ -204,13 +319,21 @@ public class ApexService {
|
|||||||
* <p>This is the deepest NABARD may go. The level below a branch is a
|
* <p>This is the deepest NABARD may go. The level below a branch is a
|
||||||
* borrower, and that belongs to the bank.
|
* borrower, and that belongs to the bank.
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> bankBranches(String institutionCode) {
|
public List<Map<String, Object>> bankBranches(String institutionCode, TenantContext tenant) {
|
||||||
Institution bank = institutionRepository.findByCode(institutionCode)
|
Institution bank = institutionRepository.findByCode(institutionCode)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("No institution " + institutionCode));
|
.orElseThrow(() -> new IllegalArgumentException("No institution " + institutionCode));
|
||||||
|
|
||||||
|
List<Enterprise> 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<Long, List<Enterprise>> byBranch = new LinkedHashMap<>();
|
Map<Long, List<Enterprise>> byBranch = new LinkedHashMap<>();
|
||||||
enterpriseRepository.findByInstitutionIdOrderByScoreDesc(bank.getId())
|
supervised.forEach(e -> byBranch.computeIfAbsent(e.getBranchId(), k -> new ArrayList<>()).add(e));
|
||||||
.forEach(e -> byBranch.computeIfAbsent(e.getBranchId(), k -> new ArrayList<>()).add(e));
|
|
||||||
|
|
||||||
Map<Long, String> districtNames = new HashMap<>();
|
Map<Long, String> districtNames = new HashMap<>();
|
||||||
districtRepository.findAll().forEach(d -> districtNames.put(d.getId(), d.getName()));
|
districtRepository.findAll().forEach(d -> districtNames.put(d.getId(), d.getName()));
|
||||||
@@ -218,7 +341,7 @@ public class ApexService {
|
|||||||
List<Map<String, Object>> out = new ArrayList<>();
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
for (Branch branch : branchRepository.findByInstitutionIdAndActiveTrueOrderByName(bank.getId())) {
|
for (Branch branch : branchRepository.findByInstitutionIdAndActiveTrueOrderByName(bank.getId())) {
|
||||||
List<Enterprise> book = byBranch.getOrDefault(branch.getId(), List.of());
|
List<Enterprise> book = byBranch.getOrDefault(branch.getId(), List.of());
|
||||||
Map<String, Object> row = new LinkedHashMap<>(cell("name", branch.getName(), book));
|
Map<String, Object> row = new LinkedHashMap<>(cell("name", branch.getName(), book, tenant));
|
||||||
row.put("branchCode", branch.getCode());
|
row.put("branchCode", branch.getCode());
|
||||||
row.put("region", branch.getRegion() == null ? "—" : branch.getRegion());
|
row.put("region", branch.getRegion() == null ? "—" : branch.getRegion());
|
||||||
row.put("blocks", branch.getBlocks() == null ? "—" : branch.getBlocks());
|
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. */
|
/** Stress by district — the unit NABARD plans ground-level credit in. */
|
||||||
public List<Map<String, Object>> byDistrict() {
|
public List<Map<String, Object>> byDistrict(TenantContext tenant) {
|
||||||
Map<Long, String> names = new HashMap<>();
|
Map<Long, String> names = new HashMap<>();
|
||||||
districtRepository.findAll().forEach(d -> names.put(d.getId(), d.getName()));
|
districtRepository.findAll().forEach(d -> names.put(d.getId(), d.getName()));
|
||||||
|
|
||||||
Map<Long, List<Enterprise>> grouped = new LinkedHashMap<>();
|
Map<Long, List<Enterprise>> grouped = new LinkedHashMap<>();
|
||||||
enterpriseRepository.findAll().forEach(e ->
|
inScope(tenant).forEach(e ->
|
||||||
grouped.computeIfAbsent(e.getDistrictId(), k -> new ArrayList<>()).add(e));
|
grouped.computeIfAbsent(e.getDistrictId(), k -> new ArrayList<>()).add(e));
|
||||||
|
|
||||||
List<Map<String, Object>> out = new ArrayList<>();
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
grouped.forEach((districtId, list) -> out.add(cell(
|
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"))));
|
out.sort(Comparator.comparing(m -> String.valueOf(m.get("districtName"))));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stress by sector — the unit a shock actually arrives in. */
|
/** Stress by sector — the unit a shock actually arrives in. */
|
||||||
public List<Map<String, Object>> bySector() {
|
public List<Map<String, Object>> bySector(TenantContext tenant) {
|
||||||
Map<Long, String> names = new HashMap<>();
|
Map<Long, String> names = new HashMap<>();
|
||||||
sectorRepository.findAll().forEach(s -> names.put(s.getId(), s.getLabel()));
|
sectorRepository.findAll().forEach(s -> names.put(s.getId(), s.getLabel()));
|
||||||
|
|
||||||
Map<Long, List<Enterprise>> grouped = new LinkedHashMap<>();
|
Map<Long, List<Enterprise>> grouped = new LinkedHashMap<>();
|
||||||
enterpriseRepository.findAll().forEach(e ->
|
inScope(tenant).forEach(e ->
|
||||||
grouped.computeIfAbsent(e.getSectorId(), k -> new ArrayList<>()).add(e));
|
grouped.computeIfAbsent(e.getSectorId(), k -> new ArrayList<>()).add(e));
|
||||||
|
|
||||||
List<Map<String, Object>> out = new ArrayList<>();
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
grouped.forEach((sectorId, list) -> out.add(cell(
|
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"))));
|
out.sort(Comparator.comparing(m -> String.valueOf(m.get("sectorName"))));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -269,11 +392,11 @@ public class ApexService {
|
|||||||
* missing district reads as "no activity", which is a different and wrong
|
* missing district reads as "no activity", which is a different and wrong
|
||||||
* claim.
|
* claim.
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> cell(String labelKey, String label, List<Enterprise> list) {
|
private Map<String, Object> cell(String labelKey, String label, List<Enterprise> list, TenantContext tenant) {
|
||||||
Map<String, Object> row = new LinkedHashMap<>();
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put(labelKey, label);
|
row.put(labelKey, label);
|
||||||
row.put("enterprises", list.size());
|
row.put("enterprises", list.size());
|
||||||
if (list.size() < minCellSize) {
|
if (list.size() < minCellFor(tenant)) {
|
||||||
row.put("suppressed", true);
|
row.put("suppressed", true);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -297,20 +420,20 @@ public class ApexService {
|
|||||||
* programme. Both are shown; the ordering says which NABARD should read
|
* programme. Both are shown; the ordering says which NABARD should read
|
||||||
* first.
|
* first.
|
||||||
*/
|
*/
|
||||||
public Map<String, Object> digest() {
|
public Map<String, Object> digest(TenantContext tenant) {
|
||||||
List<Map<String, Object>> districts = byDistrict().stream()
|
List<Map<String, Object>> districts = byDistrict(tenant).stream()
|
||||||
.filter(m -> !Boolean.TRUE.equals(m.get("suppressed")))
|
.filter(m -> !Boolean.TRUE.equals(m.get("suppressed")))
|
||||||
.filter(m -> asLong(m.get("red")) > 0)
|
.filter(m -> asLong(m.get("red")) > 0)
|
||||||
.sorted((a, b) -> Long.compare(asLong(b.get("red")), asLong(a.get("red"))))
|
.sorted((a, b) -> Long.compare(asLong(b.get("red")), asLong(a.get("red"))))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
List<Map<String, Object>> sectors = bySector().stream()
|
List<Map<String, Object>> sectors = bySector(tenant).stream()
|
||||||
.filter(m -> !Boolean.TRUE.equals(m.get("suppressed")))
|
.filter(m -> !Boolean.TRUE.equals(m.get("suppressed")))
|
||||||
.filter(m -> asLong(m.get("red")) > 0)
|
.filter(m -> asLong(m.get("red")) > 0)
|
||||||
.sorted((a, b) -> Long.compare(asLong(b.get("red")), asLong(a.get("red"))))
|
.sorted((a, b) -> Long.compare(asLong(b.get("red")), asLong(a.get("red"))))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
List<Map<String, Object>> banks = bankScorecard().stream()
|
List<Map<String, Object>> banks = bankScorecard(tenant).stream()
|
||||||
.filter(m -> !Boolean.TRUE.equals(m.get("suppressed")))
|
.filter(m -> !Boolean.TRUE.equals(m.get("suppressed")))
|
||||||
.sorted((a, b) -> Double.compare(asDouble(a.get("responseRatePct")), asDouble(b.get("responseRatePct"))))
|
.sorted((a, b) -> Double.compare(asDouble(a.get("responseRatePct")), asDouble(b.get("responseRatePct"))))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -328,7 +451,7 @@ public class ApexService {
|
|||||||
// ── coverage of the institutional tier itself ───────────
|
// ── coverage of the institutional tier itself ───────────
|
||||||
|
|
||||||
/** Which banks and branches are on the platform, for the admin screen. */
|
/** Which banks and branches are on the platform, for the admin screen. */
|
||||||
public List<Map<String, Object>> institutions() {
|
public List<Map<String, Object>> institutions(TenantContext tenant) {
|
||||||
List<Map<String, Object>> out = new ArrayList<>();
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
for (Institution institution : institutionRepository.findByActiveTrueOrderByName()) {
|
for (Institution institution : institutionRepository.findByActiveTrueOrderByName()) {
|
||||||
List<Branch> branches = branchRepository
|
List<Branch> 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. */
|
/** Days since the newest scoring run touched the book — the freshness of all of the above. */
|
||||||
public long dataAgeDays() {
|
public long dataAgeDays(TenantContext tenant) {
|
||||||
return enterpriseRepository.findAll().stream()
|
return inScope(tenant).stream()
|
||||||
.map(Enterprise::getLastScoredAt)
|
.map(Enterprise::getLastScoredAt)
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.max(Comparator.naturalOrder())
|
.max(Comparator.naturalOrder())
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<Map<String, Object>> offices(TenantContext tenant, boolean populatedOnly) {
|
||||||
|
Long scoped = tenant.regionalOfficeFilter();
|
||||||
|
Context ctx = load();
|
||||||
|
|
||||||
|
List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (RegionalOffice office : ctx.offices) {
|
||||||
|
if (scoped != null && !office.getId().equals(scoped)) continue;
|
||||||
|
Map<String, Object> 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.
|
||||||
|
*
|
||||||
|
* <p>An office the caller is not entitled to returns <b>404</b>, 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<Map<String, Object>> 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<Long> districts = districtIdsOf(office, ctx);
|
||||||
|
Map<Long, List<Enterprise>> 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<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
byInstitution.forEach((institutionId, enterprises) -> {
|
||||||
|
Institution institution = ctx.institutionsById.get(institutionId);
|
||||||
|
if (institution == null) return;
|
||||||
|
Map<String, Object> 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<String, Object> summarise(RegionalOffice office, Context ctx, TenantContext tenant) {
|
||||||
|
Set<Long> districts = districtIdsOf(office, ctx);
|
||||||
|
List<Enterprise> enterprises = new ArrayList<>();
|
||||||
|
Set<Long> 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<String, Object> 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.
|
||||||
|
*
|
||||||
|
* <p>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<String, Object> bandCounts(List<Enterprise> enterprises, TenantContext tenant) {
|
||||||
|
Map<String, Object> 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<Long> districtIdsOf(RegionalOffice office, Context ctx) {
|
||||||
|
List<String> states = ctx.statesByOffice.getOrDefault(office.getId(), List.of());
|
||||||
|
Set<Long> 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<RegionalOffice> offices = List.of();
|
||||||
|
List<District> districts = List.of();
|
||||||
|
List<Branch> branches = List.of();
|
||||||
|
final Map<Long, Institution> institutionsById = new HashMap<>();
|
||||||
|
final Map<Long, List<String>> statesByOffice = new HashMap<>();
|
||||||
|
final Map<Long, List<Enterprise>> enterprisesByBranch = new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import jakarta.validation.Valid;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** The only unauthenticated surface. */
|
/** The only unauthenticated surface. */
|
||||||
@@ -29,6 +30,24 @@ public class AuthController {
|
|||||||
return ApiResponse.ok("Signed in", authService.appLogin(request));
|
return ApiResponse.ok("Signed in", authService.appLogin(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Regional Offices, for the sign-in page's office picker.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p><b>Choosing an office here grants nothing.</b> 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<List<Map<String, String>>> regionalOffices() {
|
||||||
|
return ApiResponse.ok(authService.regionalOfficeDirectory());
|
||||||
|
}
|
||||||
|
|
||||||
/** Liveness probe, also used by the UI to decide whether to fall back to demo data. */
|
/** Liveness probe, also used by the UI to decide whether to fall back to demo data. */
|
||||||
@GetMapping("/health")
|
@GetMapping("/health")
|
||||||
public ApiResponse<Map<String, String>> health() {
|
public ApiResponse<Map<String, String>> health() {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseSessionResponse;
|
|||||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.OfficerSessionResponse;
|
import com.lincfusion.udyamlinc.dto.UdyamResponses.OfficerSessionResponse;
|
||||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||||
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
|
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.Sector;
|
||||||
import com.lincfusion.udyamlinc.master.SectorRepository;
|
import com.lincfusion.udyamlinc.master.SectorRepository;
|
||||||
import com.lincfusion.udyamlinc.security.JwtUtil;
|
import com.lincfusion.udyamlinc.security.JwtUtil;
|
||||||
@@ -17,8 +19,11 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign-in for both surfaces: resolve the user, verify the secret, issue a token
|
* 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 UdyamUserRepository userRepository;
|
||||||
private final EnterpriseRepository enterpriseRepository;
|
private final EnterpriseRepository enterpriseRepository;
|
||||||
private final SectorRepository sectorRepository;
|
private final SectorRepository sectorRepository;
|
||||||
|
private final RegionalOfficeRepository regionalOfficeRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
|
|
||||||
@@ -43,6 +49,38 @@ public class AuthService {
|
|||||||
* pinned to their own. The distinction is made here, once, rather than being
|
* pinned to their own. The distinction is made here, once, rather than being
|
||||||
* re-derived by every endpoint.
|
* re-derived by every endpoint.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Offices that have a sign-in, for the login page's picker.
|
||||||
|
*
|
||||||
|
* <p>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<Map<String, String>> regionalOfficeDirectory() {
|
||||||
|
Map<Long, String> usernames = new LinkedHashMap<>();
|
||||||
|
userRepository.findAll().forEach(u -> {
|
||||||
|
if (u.getRegionalOfficeId() != null && Boolean.TRUE.equals(u.getActive())) {
|
||||||
|
usernames.putIfAbsent(u.getRegionalOfficeId(), u.getUsername());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Map<String, String>> out = new ArrayList<>();
|
||||||
|
for (RegionalOffice office : regionalOfficeRepository.findByActiveTrueOrderByNameAsc()) {
|
||||||
|
String username = usernames.get(office.getId());
|
||||||
|
if (username == null) continue;
|
||||||
|
Map<String, String> 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
|
@Transactional
|
||||||
public OfficerSessionResponse officerLogin(OfficerLoginRequest request) {
|
public OfficerSessionResponse officerLogin(OfficerLoginRequest request) {
|
||||||
UdyamUser user = userRepository.findByUsernameIgnoreCase(request.username().trim())
|
UdyamUser user = userRepository.findByUsernameIgnoreCase(request.username().trim())
|
||||||
@@ -54,8 +92,12 @@ public class AuthService {
|
|||||||
verify(user, request.password());
|
verify(user, request.password());
|
||||||
touch(user);
|
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())
|
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(),
|
: jwtUtil.generateBankToken(user.getUsername(), user.getRole(), user.getBlocks(),
|
||||||
user.getInstitutionId(), user.getBranchId());
|
user.getInstitutionId(), user.getBranchId());
|
||||||
List<String> blocks = user.getBlocks() == null || user.getBlocks().isBlank()
|
List<String> blocks = user.getBlocks() == null || user.getBlocks().isBlank()
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ public class UdyamUser {
|
|||||||
/** Set for branch-scoped users; null for a head-office or NABARD user. */
|
/** Set for branch-scoped users; null for a head-office or NABARD user. */
|
||||||
private Long branchId;
|
private Long branchId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NABARD Regional Office this user works in, or null for head office.
|
||||||
|
*
|
||||||
|
* <p>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)
|
@Column(nullable = false)
|
||||||
private Boolean active;
|
private Boolean active;
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>An RO is <b>NABARD</b>, 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.
|
||||||
|
*
|
||||||
|
* <p>Getting that inheritance right is the point. If an RO could open an
|
||||||
|
* enterprise, the sentence the whole platform rests on — <em>because the
|
||||||
|
* supervisory tiers can never reach a borrower, portfolio-wide early warning can
|
||||||
|
* be granted to them at all</em> — would stop being true, and a partner bank's
|
||||||
|
* reason for connecting its book would go with it.
|
||||||
|
*
|
||||||
|
* <p>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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RegionalOffice, Long> {
|
||||||
|
|
||||||
|
Optional<RegionalOffice> findByCode(String code);
|
||||||
|
|
||||||
|
List<RegionalOffice> findByActiveTrueOrderByNameAsc();
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.lincfusion.udyamlinc.institution;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which states an office supervises.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>The reverse direction is what makes the tenancy work — <b>a state belongs to
|
||||||
|
* exactly one office</b>. 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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
@@ -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<RegionalOfficeState, Long> {
|
||||||
|
|
||||||
|
Optional<RegionalOfficeState> findByState(String state);
|
||||||
|
|
||||||
|
List<RegionalOfficeState> findByRegionalOfficeId(Long regionalOfficeId);
|
||||||
|
}
|
||||||
@@ -75,6 +75,10 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
role,
|
role,
|
||||||
jwtUtil.institutionId(token),
|
jwtUtil.institutionId(token),
|
||||||
jwtUtil.branchId(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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,11 +54,27 @@ public class JwtUtil {
|
|||||||
* claim is what {@code TenantContext} reads to mean exactly that.
|
* claim is what {@code TenantContext} reads to mean exactly that.
|
||||||
*/
|
*/
|
||||||
public String generateApexToken(String username, String role, Long institutionId) {
|
public String generateApexToken(String username, String role, Long institutionId) {
|
||||||
|
return generateApexToken(username, role, institutionId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NABARD session narrowed to one Regional Office.
|
||||||
|
*
|
||||||
|
* <p>Still {@code scope=NABARD}, because an RO <em>is</em> 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.
|
||||||
|
*
|
||||||
|
* <p>A null office is head office: apex over the whole country.
|
||||||
|
*/
|
||||||
|
public String generateApexToken(String username, String role, Long institutionId, Long regionalOfficeId) {
|
||||||
Map<String, Object> claims = new HashMap<>();
|
Map<String, Object> claims = new HashMap<>();
|
||||||
claims.put("realm", "UDYAM");
|
claims.put("realm", "UDYAM");
|
||||||
claims.put("scope", "NABARD");
|
claims.put("scope", "NABARD");
|
||||||
claims.put("role", role);
|
claims.put("role", role);
|
||||||
claims.put("institutionId", institutionId);
|
claims.put("institutionId", institutionId);
|
||||||
|
if (regionalOfficeId != null) claims.put("regionalOfficeId", regionalOfficeId);
|
||||||
return build(claims, username);
|
return build(claims, username);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +119,11 @@ public class JwtUtil {
|
|||||||
return String.valueOf(parse(token).get("role"));
|
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) {
|
public Long institutionId(String token) {
|
||||||
return longClaim(parse(token), "institutionId");
|
return longClaim(parse(token), "institutionId");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,43 @@ public class TenantContext {
|
|||||||
/** Set only for an ENTERPRISE session. */
|
/** Set only for an ENTERPRISE session. */
|
||||||
private final String enterpriseId;
|
private final String enterpriseId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set only for a NABARD Regional Office session; null for head office.
|
||||||
|
*
|
||||||
|
* <p>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() {
|
public boolean isApex() {
|
||||||
return tier == Tier.APEX;
|
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.
|
||||||
|
*
|
||||||
|
* <p>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() {
|
public boolean isBank() {
|
||||||
return tier == Tier.BANK;
|
return tier == Tier.BANK;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<String> 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<OfficeSpec> 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.
|
||||||
|
*
|
||||||
|
* <p>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<RegionalOffice> 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<RegionalOfficeState> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user