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
@@ -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<List<String>> appEnrolled(
TenantContext tenant,
@RequestParam(defaultValue = "ALL") String block) {
return ApiResponse.ok(officerService.appEnrolled(tenant, block));
}
@GetMapping("/enterprises/{enterpriseId}")
public ApiResponse<EnterpriseDetailResponse> detail(TenantContext tenant, @PathVariable String enterpriseId) {
return ApiResponse.ok(officerService.detail(tenant, enterpriseId));