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<>(); } }