fix(officer): scope reasons and forecasts to the run that produced the score

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) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-25 13:44:22 +05:30
co-authored by Claude Opus 5
parent 6b4aec468d
commit e7c93e29ea
6 changed files with 108 additions and 4 deletions
@@ -9,4 +9,7 @@ public interface UdyamUserRepository extends JpaRepository<UdyamUser, Long> {
Optional<UdyamUser> findByUsernameIgnoreCase(String username); Optional<UdyamUser> findByUsernameIgnoreCase(String username);
Optional<UdyamUser> findByEnterpriseIdAndScopeIgnoreCase(String enterpriseId, String scope); Optional<UdyamUser> findByEnterpriseIdAndScopeIgnoreCase(String enterpriseId, String scope);
java.util.List<UdyamUser> findByScopeIgnoreCaseAndEnterpriseIdIn(
String scope, java.util.Collection<String> enterpriseIds);
} }
@@ -54,7 +54,22 @@ public final class UdyamResponses {
Integer score, Integer score,
String band, String band,
List<Integer> trend, List<Integer> 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.
*
* <p>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. */ /** Dashboard KPI tiles plus the enterprises-by-risk register. */
@@ -40,6 +40,14 @@ public class OfficerController {
return ApiResponse.ok(officerService.enterprises(tenant, block)); return ApiResponse.ok(officerService.enterprises(tenant, block));
} }
/** Ids on this book that have a phone-app login. */
@GetMapping("/app-enrolled")
public ApiResponse<List<String>> appEnrolled(
TenantContext tenant,
@RequestParam(defaultValue = "ALL") String block) {
return ApiResponse.ok(officerService.appEnrolled(tenant, block));
}
@GetMapping("/enterprises/{enterpriseId}") @GetMapping("/enterprises/{enterpriseId}")
public ApiResponse<EnterpriseDetailResponse> detail(TenantContext tenant, @PathVariable String enterpriseId) { public ApiResponse<EnterpriseDetailResponse> detail(TenantContext tenant, @PathVariable String enterpriseId) {
return ApiResponse.ok(officerService.detail(tenant, enterpriseId)); return ApiResponse.ok(officerService.detail(tenant, enterpriseId));
@@ -41,6 +41,7 @@ public class OfficerService {
/** Sentinel used by the console when no block filter is applied. */ /** Sentinel used by the console when no block filter is applied. */
public static final String ALL_BLOCKS = "ALL"; public static final String ALL_BLOCKS = "ALL";
private final com.lincfusion.udyamlinc.auth.UdyamUserRepository userRepository;
private final EnterpriseRepository enterpriseRepository; private final EnterpriseRepository enterpriseRepository;
private final LedgerEntryRepository ledgerRepository; private final LedgerEntryRepository ledgerRepository;
private final SectorRepository sectorRepository; private final SectorRepository sectorRepository;
@@ -66,6 +67,28 @@ public class OfficerService {
return toRows(enterprisesIn(tenant, block)); return toRows(enterprisesIn(tenant, block));
} }
/**
* Which of this caller's borrowers can sign in to the phone app.
*
* <p>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.
*
* <p>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<String> appEnrolled(TenantContext tenant, String block) {
List<String> 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) { public EnterpriseDetailResponse detail(TenantContext tenant, String enterpriseId) {
Enterprise enterprise = requireEnterprise(tenant, enterpriseId); Enterprise enterprise = requireEnterprise(tenant, enterpriseId);
StressScore score = scoreRepository.findFirstByEnterpriseIdOrderByComputedAtDesc(enterpriseId).orElse(null); StressScore score = scoreRepository.findFirstByEnterpriseIdOrderByComputedAtDesc(enterpriseId).orElse(null);
@@ -75,8 +98,18 @@ public class OfficerService {
sectorCode(enterprise), sectorCode(enterprise),
districtName(enterprise), districtName(enterprise),
score, score,
reasonRepository.findByEnterpriseIdOrderByRankAsc(enterpriseId), // Scoped to the run the score came from. Unscoped, this returned
pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId), // 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), alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId),
visitRepository.findByEnterpriseIdOrderByDueDateAsc(enterpriseId), visitRepository.findByEnterpriseIdOrderByDueDateAsc(enterpriseId),
ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream().limit(60).toList()); ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream().limit(60).toList());
@@ -238,6 +271,23 @@ public class OfficerService {
Map<String, List<StressScore>> history = scoreRepository.findAll().stream() Map<String, List<StressScore>> history = scoreRepository.findAll().stream()
.collect(Collectors.groupingBy(StressScore::getEnterpriseId)); .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<String, ForecastPoint> 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<String, ReasonCode> 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() return rows.stream()
.map(e -> new EnterpriseRow( .map(e -> new EnterpriseRow(
e.getId(), e.getId(),
@@ -254,7 +304,15 @@ public class OfficerService {
e.getScore(), e.getScore(),
e.getBand(), e.getBand(),
trendOf(history.get(e.getEnterpriseId()), e.getScore()), 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(); .toList();
} }
@@ -9,4 +9,15 @@ public interface ForecastPointRepository extends JpaRepository<ForecastPoint, Lo
List<ForecastPoint> findByRunIdOrderByHorizonAsc(Long runId); List<ForecastPoint> findByRunIdOrderByHorizonAsc(Long runId);
List<ForecastPoint> findByEnterpriseIdOrderByHorizonAsc(String enterpriseId); List<ForecastPoint> findByEnterpriseIdOrderByHorizonAsc(String enterpriseId);
/**
* The path from one run.
*
* <p>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<ForecastPoint> findByEnterpriseIdAndRunIdOrderByHorizonAsc(String enterpriseId, Long runId);
} }
@@ -9,4 +9,13 @@ public interface ReasonCodeRepository extends JpaRepository<ReasonCode, Long> {
List<ReasonCode> findByRunIdOrderByRankAsc(Long runId); List<ReasonCode> findByRunIdOrderByRankAsc(Long runId);
List<ReasonCode> findByEnterpriseIdOrderByRankAsc(String enterpriseId); List<ReasonCode> findByEnterpriseIdOrderByRankAsc(String enterpriseId);
/**
* The drivers from one run, for one enterprise.
*
* <p>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<ReasonCode> findByEnterpriseIdAndRunIdOrderByRankAsc(String enterpriseId, Long runId);
} }