diff --git a/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUserRepository.java b/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUserRepository.java index 4e32b51..92d553d 100644 --- a/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUserRepository.java +++ b/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUserRepository.java @@ -9,4 +9,7 @@ public interface UdyamUserRepository extends JpaRepository { Optional findByUsernameIgnoreCase(String username); Optional findByEnterpriseIdAndScopeIgnoreCase(String enterpriseId, String scope); + + java.util.List findByScopeIgnoreCaseAndEnterpriseIdIn( + String scope, java.util.Collection enterpriseIds); } diff --git a/src/main/java/com/lincfusion/udyamlinc/dto/UdyamResponses.java b/src/main/java/com/lincfusion/udyamlinc/dto/UdyamResponses.java index 7a2102e..52be3e5 100644 --- a/src/main/java/com/lincfusion/udyamlinc/dto/UdyamResponses.java +++ b/src/main/java/com/lincfusion/udyamlinc/dto/UdyamResponses.java @@ -54,7 +54,22 @@ public final class UdyamResponses { Integer score, String band, List trend, - String lastUpdated) { + String lastUpdated, + + /** + * Months until the downside case first fails to cover the instalment, + * or null if it never does over the horizon. + * + *

Here because lead time is the product. A dashboard that ranks by + * score alone shows what a bureau score already shows; ranking by when + * trouble arrives is the thing this platform can do that they cannot. + */ + Integer firstBreachHorizon, + String firstBreachMonth, + + /** The top-ranked reason sentence from the run that produced the score. */ + String topDriver, + String topDriverHi) { } /** Dashboard KPI tiles plus the enterprises-by-risk register. */ diff --git a/src/main/java/com/lincfusion/udyamlinc/officer/OfficerController.java b/src/main/java/com/lincfusion/udyamlinc/officer/OfficerController.java index d1cb276..a950dba 100644 --- a/src/main/java/com/lincfusion/udyamlinc/officer/OfficerController.java +++ b/src/main/java/com/lincfusion/udyamlinc/officer/OfficerController.java @@ -40,6 +40,14 @@ public class OfficerController { return ApiResponse.ok(officerService.enterprises(tenant, block)); } + /** Ids on this book that have a phone-app login. */ + @GetMapping("/app-enrolled") + public ApiResponse> appEnrolled( + TenantContext tenant, + @RequestParam(defaultValue = "ALL") String block) { + return ApiResponse.ok(officerService.appEnrolled(tenant, block)); + } + @GetMapping("/enterprises/{enterpriseId}") public ApiResponse detail(TenantContext tenant, @PathVariable String enterpriseId) { return ApiResponse.ok(officerService.detail(tenant, enterpriseId)); diff --git a/src/main/java/com/lincfusion/udyamlinc/officer/OfficerService.java b/src/main/java/com/lincfusion/udyamlinc/officer/OfficerService.java index 547066d..9fe7960 100644 --- a/src/main/java/com/lincfusion/udyamlinc/officer/OfficerService.java +++ b/src/main/java/com/lincfusion/udyamlinc/officer/OfficerService.java @@ -41,6 +41,7 @@ public class OfficerService { /** Sentinel used by the console when no block filter is applied. */ public static final String ALL_BLOCKS = "ALL"; + private final com.lincfusion.udyamlinc.auth.UdyamUserRepository userRepository; private final EnterpriseRepository enterpriseRepository; private final LedgerEntryRepository ledgerRepository; private final SectorRepository sectorRepository; @@ -66,6 +67,28 @@ public class OfficerService { return toRows(enterprisesIn(tenant, block)); } + /** + * Which of this caller's borrowers can sign in to the phone app. + * + *

Not every borrower is enrolled, and that is the design rather than a gap: + * the platform scores a book from core banking and UPI whether or not the owner + * ever installs anything. A console that offered to open the app for a borrower + * who has none would be promising a screen that does not exist. + * + *

Scoped by intersecting with the caller's own book, so this cannot be used + * to discover that an id exists on another bank's book. + */ + public List appEnrolled(TenantContext tenant, String block) { + List mine = enterprisesIn(tenant, block).stream() + .map(Enterprise::getEnterpriseId).toList(); + if (mine.isEmpty()) return List.of(); + return userRepository.findByScopeIgnoreCaseAndEnterpriseIdIn("ENTERPRISE", mine).stream() + .map(com.lincfusion.udyamlinc.auth.UdyamUser::getEnterpriseId) + .filter(java.util.Objects::nonNull) + .sorted() + .toList(); + } + public EnterpriseDetailResponse detail(TenantContext tenant, String enterpriseId) { Enterprise enterprise = requireEnterprise(tenant, enterpriseId); StressScore score = scoreRepository.findFirstByEnterpriseIdOrderByComputedAtDesc(enterpriseId).orElse(null); @@ -75,8 +98,18 @@ public class OfficerService { sectorCode(enterprise), districtName(enterprise), score, - reasonRepository.findByEnterpriseIdOrderByRankAsc(enterpriseId), - pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId), + // Scoped to the run the score came from. Unscoped, this returned + // every reason ever stored for the enterprise, so tonight's model + // score arrived beside sentences a rule engine wrote months ago — + // one number, two engines explaining it. If the score predates the + // reason table, an empty list is the honest answer. + score == null || score.getRunId() == null + ? List.of() + : reasonRepository.findByEnterpriseIdAndRunIdOrderByRankAsc(enterpriseId, score.getRunId()), + // Scoped to the score's run, for the same reason the reason codes are. + score == null || score.getRunId() == null + ? List.of() + : pointRepository.findByEnterpriseIdAndRunIdOrderByHorizonAsc(enterpriseId, score.getRunId()), alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId), visitRepository.findByEnterpriseIdOrderByDueDateAsc(enterpriseId), ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream().limit(60).toList()); @@ -238,6 +271,23 @@ public class OfficerService { Map> history = scoreRepository.findAll().stream() .collect(Collectors.groupingBy(StressScore::getEnterpriseId)); + // Lead time and the leading reason, both from the newest run only. Three + // bulk queries rather than two per enterprise — this feeds a list of 263. + Long latestRun = scoreRepository.findAll().stream() + .map(StressScore::getRunId).filter(java.util.Objects::nonNull) + .max(Long::compareTo).orElse(null); + + Map firstBreach = latestRun == null ? Map.of() + : pointRepository.findByRunIdOrderByHorizonAsc(latestRun).stream() + .filter(p -> Boolean.TRUE.equals(p.getBreach())) + .collect(Collectors.toMap(ForecastPoint::getEnterpriseId, Function.identity(), + (a, b) -> a.getHorizon() <= b.getHorizon() ? a : b)); + + Map topReason = latestRun == null ? Map.of() + : reasonRepository.findByRunIdOrderByRankAsc(latestRun).stream() + .collect(Collectors.toMap(ReasonCode::getEnterpriseId, Function.identity(), + (a, b) -> a.getRank() <= b.getRank() ? a : b)); + return rows.stream() .map(e -> new EnterpriseRow( e.getId(), @@ -254,7 +304,15 @@ public class OfficerService { e.getScore(), e.getBand(), trendOf(history.get(e.getEnterpriseId()), e.getScore()), - e.getLastScoredAt() == null ? null : e.getLastScoredAt().toString())) + e.getLastScoredAt() == null ? null : e.getLastScoredAt().toString(), + firstBreach.containsKey(e.getEnterpriseId()) + ? firstBreach.get(e.getEnterpriseId()).getHorizon() : null, + firstBreach.containsKey(e.getEnterpriseId()) + ? firstBreach.get(e.getEnterpriseId()).getMonth() : null, + topReason.containsKey(e.getEnterpriseId()) + ? topReason.get(e.getEnterpriseId()).getSentenceEn() : null, + topReason.containsKey(e.getEnterpriseId()) + ? topReason.get(e.getEnterpriseId()).getSentenceHi() : null)) .toList(); } diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPointRepository.java b/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPointRepository.java index ef14be5..b9e3b66 100644 --- a/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPointRepository.java +++ b/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPointRepository.java @@ -9,4 +9,15 @@ public interface ForecastPointRepository extends JpaRepository findByRunIdOrderByHorizonAsc(Long runId); List findByEnterpriseIdOrderByHorizonAsc(String enterpriseId); + + /** + * The path from one run. + * + *

The unscoped variant returns every point ever forecast for the + * enterprise — seventeen runs deep on the demo book — so a six-month chart + * drew a hundred points and repeated every month. A forecast is a statement + * made at a moment; reading them all at once is not a longer forecast, it is + * several contradictory ones overlaid. + */ + List findByEnterpriseIdAndRunIdOrderByHorizonAsc(String enterpriseId, Long runId); } diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ReasonCodeRepository.java b/src/main/java/com/lincfusion/udyamlinc/risk/ReasonCodeRepository.java index 95018ad..470d120 100644 --- a/src/main/java/com/lincfusion/udyamlinc/risk/ReasonCodeRepository.java +++ b/src/main/java/com/lincfusion/udyamlinc/risk/ReasonCodeRepository.java @@ -9,4 +9,13 @@ public interface ReasonCodeRepository extends JpaRepository { List findByRunIdOrderByRankAsc(Long runId); List findByEnterpriseIdOrderByRankAsc(String enterpriseId); + + /** + * The drivers from one run, for one enterprise. + * + *

The unscoped variant returns every reason ever stored for the enterprise, + * so a detail screen could show a score from tonight's model run beside + * sentences a rule engine wrote months ago — two engines explaining one number. + */ + List findByEnterpriseIdAndRunIdOrderByRankAsc(String enterpriseId, Long runId); }