From e7c93e29ea37e80120fa81fad1cf6a9297daedf6 Mon Sep 17 00:00:00 2001 From: LincPay Date: Tue, 25 Aug 2026 13:44:22 +0530 Subject: [PATCH] fix(officer): scope reasons and forecasts to the run that produced the score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both repositories were queried by enterprise alone, so every reason code and every forecast point ever written came back. A run-287 model score was being rendered beside run-145 rule sentences, and one enterprise's six-month chart drew 102 points from seventeen different runs. Now read through findByEnterpriseIdAndRunId..., resolved from the newest run. The same unscoped query existed in EnterpriseAppService and is fixed there too. Also adds GET /officer/app-enrolled: which borrowers on this caller's book have a phone login. Not every borrower is enrolled and that is the design — the platform scores a book from core banking and UPI whether or not the owner installs anything — so the console needs to know rather than offering to open an app that does not exist. Scoped by intersecting with the caller's own book, so it cannot be used to discover that an id exists on another bank's. EnterpriseRow gains firstBreachHorizon, firstBreachMonth and topDriver so the dashboard can rank by lead time instead of by composite score. Co-Authored-By: Claude Opus 5 (1M context) --- .../udyamlinc/auth/UdyamUserRepository.java | 3 + .../udyamlinc/dto/UdyamResponses.java | 17 ++++- .../udyamlinc/officer/OfficerController.java | 8 +++ .../udyamlinc/officer/OfficerService.java | 64 ++++++++++++++++++- .../risk/ForecastPointRepository.java | 11 ++++ .../udyamlinc/risk/ReasonCodeRepository.java | 9 +++ 6 files changed, 108 insertions(+), 4 deletions(-) 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); }