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. * *

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 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 enterprises(TenantContext tenant, String block) { 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); 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 alerts(TenantContext tenant, String block, String state) { Map blockOf = visibleBlocks(tenant); if (blockOf.isEmpty()) return List.of(); List 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 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 visits(TenantContext tenant, String block) { Map blockOf = visibleBlocks(tenant); if (blockOf.isEmpty()) return List.of(); List 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 sectorRollup(TenantContext tenant, String block) { List rows = enterprisesIn(tenant, block); return sectorRepository.findByActiveTrue().stream() .map(sector -> { List 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. * *

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 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 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 toRows(List rows) { Map sectors = sectorRepository.findAll().stream() .collect(Collectors.toMap(Sector::getId, Function.identity())); Map districts = districtRepository.findAll().stream() .collect(Collectors.toMap(District::getId, Function.identity())); // One query for every score, then grouped — not one query per enterprise. 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(), 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 trendOf(List scores, Integer current) { if (scores == null || scores.isEmpty()) { return current == null ? List.of() : List.of(current); } List 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 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 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. * *

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; } }