chore: initial commit — UdyamLinc backend, phases A-E
Spring Boot 4 / Java 17 / Postgres. Masters and serving (A), Agmarknet and climate ingestion (B), feature store with 14 derivations (C), SpEL rule engine and risk scoring (D). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
package com.lincfusion.udyamlinc.officer;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
||||
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 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(String block, String month) {
|
||||
List<Enterprise> rows = enterprisesIn(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(String block) {
|
||||
return toRows(enterprisesIn(block));
|
||||
}
|
||||
|
||||
public EnterpriseDetailResponse detail(String enterpriseId) {
|
||||
Enterprise enterprise = requireEnterprise(enterpriseId);
|
||||
StressScore score = scoreRepository.findFirstByEnterpriseIdOrderByComputedAtDesc(enterpriseId).orElse(null);
|
||||
|
||||
return new EnterpriseDetailResponse(
|
||||
enterprise,
|
||||
sectorCode(enterprise),
|
||||
districtName(enterprise),
|
||||
score,
|
||||
reasonRepository.findByEnterpriseIdOrderByRankAsc(enterpriseId),
|
||||
pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId),
|
||||
alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId),
|
||||
visitRepository.findByEnterpriseIdOrderByDueDateAsc(enterpriseId),
|
||||
ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream().limit(60).toList());
|
||||
}
|
||||
|
||||
public List<RiskAlert> alerts(String block, String state) {
|
||||
List<RiskAlert> all = state == null || state.isBlank() || "ALL".equalsIgnoreCase(state)
|
||||
? alertRepository.findAllByOrderByScoreDesc()
|
||||
: alertRepository.findByStateOrderByScoreDesc(state.toUpperCase());
|
||||
if (ALL_BLOCKS.equalsIgnoreCase(block)) return all;
|
||||
|
||||
// One lookup for the whole page rather than a query per alert.
|
||||
Map<String, String> blockOf = enterpriseRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(Enterprise::getEnterpriseId, Enterprise::getBlock, (a, b) -> a));
|
||||
return all.stream().filter(a -> block.equalsIgnoreCase(blockOf.get(a.getEnterpriseId()))).toList();
|
||||
}
|
||||
|
||||
/** Acknowledge / escalate / snooze / close. Every change records who and when. */
|
||||
@Transactional
|
||||
public RiskAlert disposition(String alertId, String state, String note, String actor) {
|
||||
RiskAlert alert = alertRepository.findByAlertId(alertId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No alert " + alertId));
|
||||
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);
|
||||
}
|
||||
|
||||
public List<FieldVisit> visits(String block) {
|
||||
return ALL_BLOCKS.equalsIgnoreCase(block)
|
||||
? visitRepository.findAllByOrderByPriorityAscDueDateAsc()
|
||||
: visitRepository.findByBlockOrderByPriorityAscDueDateAsc(block);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FieldVisit schedule(ScheduleVisitRequest request, String officer) {
|
||||
Enterprise enterprise = requireEnterprise(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(String block) {
|
||||
List<Enterprise> rows = enterprisesIn(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 ─────────────────────────────────────────────
|
||||
|
||||
private List<Enterprise> enterprisesIn(String block) {
|
||||
return block == null || block.isBlank() || ALL_BLOCKS.equalsIgnoreCase(block)
|
||||
? enterpriseRepository.findAllByOrderByScoreDesc()
|
||||
: enterpriseRepository.findByBlockOrderByScoreDesc(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));
|
||||
|
||||
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()))
|
||||
.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);
|
||||
}
|
||||
|
||||
private Enterprise requireEnterprise(String enterpriseId) {
|
||||
return enterpriseRepository.findByEnterpriseId(enterpriseId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No enterprise " + enterpriseId));
|
||||
}
|
||||
|
||||
private int nz(Integer v) {
|
||||
return v == null ? 0 : v;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user