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>
238 lines
10 KiB
Java
238 lines
10 KiB
Java
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<>();
|
|
}
|
|
}
|