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