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:
LincPay
2026-08-06 13:30:14 +05:30
co-authored by Claude Opus 5
commit 1b9b02f2c7
100 changed files with 7935 additions and 0 deletions
@@ -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;
}
}