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>
372 lines
18 KiB
Java
372 lines
18 KiB
Java
package com.lincfusion.udyamlinc.officer;
|
|
|
|
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
|
import com.lincfusion.udyamlinc.security.TenantContext;
|
|
import com.lincfusion.udyamlinc.dto.UdyamRequests.ScheduleVisitRequest;
|
|
import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseDetailResponse;
|
|
import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseRow;
|
|
import com.lincfusion.udyamlinc.dto.UdyamResponses.PortfolioResponse;
|
|
import com.lincfusion.udyamlinc.dto.UdyamResponses.SectorRollupResponse;
|
|
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
|
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
|
|
import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository;
|
|
import com.lincfusion.udyamlinc.master.District;
|
|
import com.lincfusion.udyamlinc.master.DistrictRepository;
|
|
import com.lincfusion.udyamlinc.master.Sector;
|
|
import com.lincfusion.udyamlinc.master.SectorRepository;
|
|
import com.lincfusion.udyamlinc.risk.*;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.function.Function;
|
|
import java.util.stream.Collectors;
|
|
|
|
/**
|
|
* Read side of the field officer console, plus the two writes an officer makes:
|
|
* dispositioning an alert and scheduling or closing a visit.
|
|
*
|
|
* <p>Everything served here is read from stored scoring runs — this service does
|
|
* not score. Scoring belongs to the risk engine, which writes the rows this
|
|
* service reads back.
|
|
*/
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
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;
|
|
private final DistrictRepository districtRepository;
|
|
private final StressScoreRepository scoreRepository;
|
|
private final ReasonCodeRepository reasonRepository;
|
|
private final ForecastPointRepository pointRepository;
|
|
private final RiskAlertRepository alertRepository;
|
|
private final FieldVisitRepository visitRepository;
|
|
|
|
public PortfolioResponse portfolio(TenantContext tenant, String block, String month) {
|
|
List<Enterprise> rows = enterprisesIn(tenant, block);
|
|
long red = countBand(rows, "RED");
|
|
long watch = countBand(rows, "WATCH");
|
|
long standard = rows.size() - red - watch;
|
|
|
|
return new PortfolioResponse(
|
|
block, month, rows.size(), 0, red, 0, watch, 0, standard, 0,
|
|
minutesSinceLastScore(rows), toRows(rows));
|
|
}
|
|
|
|
public List<EnterpriseRow> enterprises(TenantContext tenant, String 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) {
|
|
Enterprise enterprise = requireEnterprise(tenant, enterpriseId);
|
|
StressScore score = scoreRepository.findFirstByEnterpriseIdOrderByComputedAtDesc(enterpriseId).orElse(null);
|
|
|
|
return new EnterpriseDetailResponse(
|
|
enterprise,
|
|
sectorCode(enterprise),
|
|
districtName(enterprise),
|
|
score,
|
|
// 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());
|
|
}
|
|
|
|
/**
|
|
* Alerts are stored against an enterprise, not an institution, so they are
|
|
* scoped through the borrowers this caller may see rather than by a column of
|
|
* their own. Anything raised against another bank's borrower is not filtered
|
|
* out of the page — it never enters it.
|
|
*/
|
|
public List<RiskAlert> alerts(TenantContext tenant, String block, String state) {
|
|
Map<String, String> blockOf = visibleBlocks(tenant);
|
|
if (blockOf.isEmpty()) return List.of();
|
|
|
|
List<RiskAlert> all = state == null || state.isBlank() || "ALL".equalsIgnoreCase(state)
|
|
? alertRepository.findAllByOrderByScoreDesc()
|
|
: alertRepository.findByStateOrderByScoreDesc(state.toUpperCase());
|
|
|
|
return all.stream()
|
|
.filter(a -> blockOf.containsKey(a.getEnterpriseId()))
|
|
.filter(a -> ALL_BLOCKS.equalsIgnoreCase(block)
|
|
|| block.equalsIgnoreCase(blockOf.get(a.getEnterpriseId())))
|
|
.toList();
|
|
}
|
|
|
|
/** Enterprise id → block, for every borrower this caller may see. One query. */
|
|
private Map<String, String> visibleBlocks(TenantContext tenant) {
|
|
return enterprisesIn(tenant, ALL_BLOCKS).stream()
|
|
.collect(Collectors.toMap(Enterprise::getEnterpriseId, Enterprise::getBlock, (a, b) -> a));
|
|
}
|
|
|
|
/** Acknowledge / escalate / snooze / close. Every change records who and when. */
|
|
@Transactional
|
|
public RiskAlert disposition(TenantContext tenant, String alertId, String state, String note, String actor) {
|
|
RiskAlert alert = alertRepository.findByAlertId(alertId)
|
|
.orElseThrow(() -> new ResourceNotFoundException("No alert " + alertId));
|
|
// Acting on an alert is a write, so the tenancy check matters more here
|
|
// than on a read: closing another bank's alert would be invisible to them.
|
|
requireEnterprise(tenant, alert.getEnterpriseId());
|
|
String next = state.toUpperCase();
|
|
if (!List.of("OPEN", "ACKNOWLEDGED", "ESCALATED", "SNOOZED", "CLOSED").contains(next)) {
|
|
throw new IllegalArgumentException("Unknown alert state: " + state);
|
|
}
|
|
alert.setState(next);
|
|
alert.setDispositionNote(note);
|
|
alert.setDispositionedBy(actor);
|
|
alert.setDispositionedAt(LocalDateTime.now());
|
|
return alertRepository.save(alert);
|
|
}
|
|
|
|
/** Scoped the same way as alerts — through the borrowers this caller may see. */
|
|
public List<FieldVisit> visits(TenantContext tenant, String block) {
|
|
Map<String, String> blockOf = visibleBlocks(tenant);
|
|
if (blockOf.isEmpty()) return List.of();
|
|
|
|
List<FieldVisit> all = visitRepository.findAllByOrderByPriorityAscDueDateAsc();
|
|
return all.stream()
|
|
.filter(v -> blockOf.containsKey(v.getEnterpriseId()))
|
|
.filter(v -> ALL_BLOCKS.equalsIgnoreCase(block) || block.equalsIgnoreCase(v.getBlock()))
|
|
.toList();
|
|
}
|
|
|
|
@Transactional
|
|
public FieldVisit schedule(TenantContext tenant, ScheduleVisitRequest request, String officer) {
|
|
Enterprise enterprise = requireEnterprise(tenant, request.enterpriseId());
|
|
PlaybookCatalog.Action action = PlaybookCatalog.byCode(request.actionCode());
|
|
|
|
FieldVisit visit = FieldVisit.builder()
|
|
.visitId("FV-" + (9000 + visitRepository.count()))
|
|
.enterpriseId(enterprise.getEnterpriseId())
|
|
.enterpriseName(enterprise.getName())
|
|
.block(enterprise.getBlock())
|
|
.actionCode(action.code())
|
|
.action(action.label())
|
|
.actionHi(action.labelHi())
|
|
.priority("RED".equals(enterprise.getBand()) ? 1 : 2)
|
|
.dueDate(request.dueDate() == null ? LocalDate.now().plusDays(3) : LocalDate.parse(request.dueDate()))
|
|
.state("PLANNED")
|
|
.officer(officer)
|
|
.build();
|
|
return visitRepository.save(visit);
|
|
}
|
|
|
|
/** Closing a visit is the feedback edge of the monitoring loop. */
|
|
@Transactional
|
|
public FieldVisit logOutcome(String visitId, String outcome, Boolean override) {
|
|
FieldVisit visit = visitRepository.findByVisitId(visitId)
|
|
.orElseThrow(() -> new ResourceNotFoundException("No visit " + visitId));
|
|
visit.setState("DONE");
|
|
visit.setOutcome(outcome);
|
|
visit.setOfficerOverride(Boolean.TRUE.equals(override));
|
|
visit.setLoggedAt(LocalDateTime.now());
|
|
return visitRepository.save(visit);
|
|
}
|
|
|
|
/** Band mix and average score for each sector pack. */
|
|
public List<SectorRollupResponse> sectorRollup(TenantContext tenant, String block) {
|
|
List<Enterprise> rows = enterprisesIn(tenant, block);
|
|
return sectorRepository.findByActiveTrue().stream()
|
|
.map(sector -> {
|
|
List<Enterprise> mine = rows.stream()
|
|
.filter(e -> sector.getId().equals(e.getSectorId()))
|
|
.toList();
|
|
long red = countBand(mine, "RED");
|
|
long watch = countBand(mine, "WATCH");
|
|
int avg = mine.isEmpty() ? 0
|
|
: (int) Math.round(mine.stream().mapToInt(e -> nz(e.getScore())).average().orElse(0));
|
|
return new SectorRollupResponse(
|
|
sector.getCode(), sector.getLabel(), mine.size(), red, watch,
|
|
mine.size() - red - watch, avg);
|
|
})
|
|
.toList();
|
|
}
|
|
|
|
// ── helpers ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* The borrowers this caller may see, narrowed further by the block they asked
|
|
* for.
|
|
*
|
|
* <p>The institution comes from the verified token, never from the request.
|
|
* A caller with no institution on their token — which after the three-tier
|
|
* change should only be an apex user reaching a bank endpoint they are
|
|
* already barred from — gets nothing rather than everything. Failing closed
|
|
* is the only safe direction for a tenancy filter.
|
|
*/
|
|
private List<Enterprise> enterprisesIn(TenantContext tenant, String block) {
|
|
Long institutionId = tenant == null ? null : tenant.portfolioFilter();
|
|
if (institutionId == null) return List.of();
|
|
|
|
boolean allBlocks = block == null || block.isBlank() || ALL_BLOCKS.equalsIgnoreCase(block);
|
|
|
|
// A branch-scoped user sees their branch, whatever block they ask for.
|
|
if (tenant.getBranchId() != null) {
|
|
List<Enterprise> branchRows = enterpriseRepository
|
|
.findByInstitutionIdAndBranchIdOrderByScoreDesc(institutionId, tenant.getBranchId());
|
|
return allBlocks
|
|
? branchRows
|
|
: branchRows.stream().filter(e -> block.equalsIgnoreCase(e.getBlock())).toList();
|
|
}
|
|
|
|
return allBlocks
|
|
? enterpriseRepository.findByInstitutionIdOrderByScoreDesc(institutionId)
|
|
: enterpriseRepository.findByInstitutionIdAndBlockOrderByScoreDesc(institutionId, block);
|
|
}
|
|
|
|
/**
|
|
* Flatten enterprises for the console table, resolving sector and district
|
|
* codes once for the whole page rather than per row.
|
|
*/
|
|
private List<EnterpriseRow> toRows(List<Enterprise> rows) {
|
|
Map<Long, Sector> sectors = sectorRepository.findAll().stream()
|
|
.collect(Collectors.toMap(Sector::getId, Function.identity()));
|
|
Map<Long, District> districts = districtRepository.findAll().stream()
|
|
.collect(Collectors.toMap(District::getId, Function.identity()));
|
|
|
|
// One query for every score, then grouped — not one query per enterprise.
|
|
Map<String, List<StressScore>> 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<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()
|
|
.map(e -> new EnterpriseRow(
|
|
e.getId(),
|
|
e.getEnterpriseId(),
|
|
e.getName(),
|
|
e.getNameHi(),
|
|
sectors.containsKey(e.getSectorId()) ? sectors.get(e.getSectorId()).getCode() : null,
|
|
e.getBlock(),
|
|
districts.containsKey(e.getDistrictId()) ? districts.get(e.getDistrictId()).getName() : null,
|
|
e.getOwnerCount(),
|
|
e.getVintageMonths(),
|
|
e.getLoanOutstanding(),
|
|
e.getMonthlyInstalment(),
|
|
e.getScore(),
|
|
e.getBand(),
|
|
trendOf(history.get(e.getEnterpriseId()), e.getScore()),
|
|
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();
|
|
}
|
|
|
|
/** Last six scores, oldest first, padded with the current score when short. */
|
|
private List<Integer> trendOf(List<StressScore> scores, Integer current) {
|
|
if (scores == null || scores.isEmpty()) {
|
|
return current == null ? List.of() : List.of(current);
|
|
}
|
|
List<Integer> tail = scores.stream()
|
|
.sorted((a, b) -> a.getComputedAt().compareTo(b.getComputedAt()))
|
|
.map(StressScore::getScore)
|
|
.toList();
|
|
return tail.size() <= 6 ? tail : tail.subList(tail.size() - 6, tail.size());
|
|
}
|
|
|
|
private int minutesSinceLastScore(List<Enterprise> rows) {
|
|
return rows.stream()
|
|
.map(Enterprise::getLastScoredAt)
|
|
.filter(java.util.Objects::nonNull)
|
|
.max(LocalDateTime::compareTo)
|
|
.map(t -> (int) java.time.Duration.between(t, LocalDateTime.now()).toMinutes())
|
|
.orElse(0);
|
|
}
|
|
|
|
private long countBand(List<Enterprise> rows, String band) {
|
|
return rows.stream().filter(e -> band.equals(e.getBand())).count();
|
|
}
|
|
|
|
private String sectorCode(Enterprise e) {
|
|
return sectorRepository.findById(e.getSectorId()).map(Sector::getCode).orElse(null);
|
|
}
|
|
|
|
private String districtName(Enterprise e) {
|
|
return districtRepository.findById(e.getDistrictId()).map(District::getName).orElse(null);
|
|
}
|
|
|
|
/**
|
|
* One borrower, if this caller's institution carries them.
|
|
*
|
|
* <p>A row belonging to another bank raises the same "no such enterprise" as
|
|
* one that does not exist. Returning a distinguishable 403 would confirm the
|
|
* id is real and on somebody's book, which is itself the leak — an outsider
|
|
* could enumerate a competitor's portfolio without reading a single field.
|
|
*/
|
|
private Enterprise requireEnterprise(TenantContext tenant, String enterpriseId) {
|
|
Long institutionId = tenant == null ? null : tenant.portfolioFilter();
|
|
if (institutionId == null) throw new ResourceNotFoundException("No enterprise " + enterpriseId);
|
|
|
|
return enterpriseRepository.findByEnterpriseIdAndInstitutionId(enterpriseId, institutionId)
|
|
.orElseThrow(() -> new ResourceNotFoundException("No enterprise " + enterpriseId));
|
|
}
|
|
|
|
private int nz(Integer v) {
|
|
return v == null ? 0 : v;
|
|
}
|
|
}
|