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,94 @@
package com.lincfusion.udyamlinc.seed;
import java.util.List;
import java.util.Map;
/**
* Sector-specific naming and reason-sentence templates for the demo portfolio.
*
* <p>Kept apart from the seeder itself so the seeder reads as a sequence of
* steps rather than a wall of string literals. The sentences mirror those in
* UdyamLinc-UI/src/lib/demo/enterprises.ts, so the console shows the same wording
* whether it is reading the API or its own fallback.
*/
final class SeedText {
private SeedText() {
}
static final String[] PREFIX = {
"Shri", "Maa Narmada", "Jai Kisan", "Sarvodaya", "Annapurna", "Vindhya", "Betwa", "Narmada",
"Adarsh", "Nav Jeevan", "Gram Shakti", "Kisan Mitra", "Sanskar", "Prerna", "Sunrise",
"Bhagirath", "Malwa", "Sahyog", "Ekta", "Pragati", "Suvidha", "Anmol", "Nirmal", "Vaibhav",
};
static final String[] PREFIX_HI = {
"श्री", "माँ नर्मदा", "जय किसान", "सर्वोदय", "अन्नपूर्णा", "विंध्य", "बेतवा", "नर्मदा",
"आदर्श", "नव जीवन", "ग्राम शक्ति", "किसान मित्र", "संस्कार", "प्रेरणा", "सनराइज़",
"भगीरथ", "मालवा", "सहयोग", "एकता", "प्रगति", "सुविधा", "अनमोल", "निर्मल", "वैभव",
};
static final Map<String, List<String>> SUFFIX = Map.of(
"DAIRY", List.of("Dairy", "Milk Centre", "Dairy Unit", "Doodh Sangh"),
"POULTRY", List.of("Poultry Farm", "Layer Farm", "Poultry Unit"),
"FOOD_PROC", List.of("Food Products", "Flour Mill", "Masala Udyog", "Agro Foods"),
"HANDICRAFTS", List.of("Handicrafts", "Handloom", "Craft Works"),
"RURAL_RETAIL", List.of("Kirana Store", "General Store", "Provision Store"));
static final Map<String, List<String>> SUFFIX_HI = Map.of(
"DAIRY", List.of("डेयरी", "दूध केंद्र", "डेयरी यूनिट", "दूध संघ"),
"POULTRY", List.of("पोल्ट्री फ़ार्म", "लेयर फ़ार्म", "पोल्ट्री यूनिट"),
"FOOD_PROC", List.of("फ़ूड प्रोडक्ट्स", "आटा चक्की", "मसाला उद्योग", "एग्रो फ़ूड्स"),
"HANDICRAFTS", List.of("हस्तशिल्प", "हथकरघा", "क्राफ़्ट वर्क्स"),
"RURAL_RETAIL", List.of("किराना स्टोर", "जनरल स्टोर", "प्रोविज़न स्टोर"));
/** driver → { en, hi } sentence, per sector. */
static final Map<String, Map<String, String[]>> REASONS = Map.of(
"POULTRY", Map.of(
"liquidity", new String[]{"Feed costs up 18% against a 9% fall in egg realisation.", "दाना 18% महँगा और अंडे का भाव 9% गिरा है।"},
"behavioural", new String[]{"UPI collections down 23% over eight weeks.", "आठ हफ़्तों में UPI से आया पैसा 23% घटा है।"},
"trendVolatility", new String[]{"Weekly sales swinging twice as wide as last season.", "हफ़्तेवार बिक्री पिछले सीज़न से दोगुनी ऊपर-नीचे हो रही है।"},
"obligationLoad", new String[]{"Instalment now takes 41% of net monthly cash.", "किस्त अब महीने की कमाई का 41% ले रही है।"},
"externalShock", new String[]{"Heat anomaly flagged in the block for the next fortnight.", "अगले पंद्रह दिन ब्लॉक में गर्मी की चेतावनी है।"}),
"DAIRY", Map.of(
"liquidity", new String[]{"Fodder cost up 14% while procurement rate stayed flat.", "चारा 14% महँगा हुआ, दूध का रेट वही है।"},
"behavioural", new String[]{"Society payment cycle stretched from 10 to 18 days.", "समिति से भुगतान 10 दिन की जगह 18 दिन में आ रहा है।"},
"trendVolatility", new String[]{"Daily yield varying sharply across the last six weeks.", "छह हफ़्तों में रोज़ का दूध बहुत घट-बढ़ रहा है।"},
"obligationLoad", new String[]{"Two instalments now fall in the same fortnight.", "दो किस्तें एक ही पखवाड़े में आ रही हैं।"},
"externalShock", new String[]{"Rainfall deficit in the block is raising fodder prices.", "ब्लॉक में कम बारिश से चारा महँगा हो रहा है।"}),
"FOOD_PROC", Map.of(
"liquidity", new String[]{"Raw material prepayment is locking up working capital.", "कच्चे माल का अग्रिम भुगतान पूँजी रोक रहा है।"},
"behavioural", new String[]{"Buyer settlements slipping past the agreed 15 days.", "खरीदार 15 दिन की जगह देर से पैसा दे रहे हैं।"},
"trendVolatility", new String[]{"Order volume swinging with festival demand.", "त्योहारों के साथ ऑर्डर बहुत घट-बढ़ रहे हैं।"},
"obligationLoad", new String[]{"Instalment plus rent exceeds 50% of monthly inflow.", "किस्त और किराया मिलकर आधी से ज़्यादा कमाई ले रहे हैं।"},
"externalShock", new String[]{"Mandi price shock in the primary input crop.", "मुख्य फ़सल के मंडी भाव में तेज़ बदलाव है।"}),
"HANDICRAFTS", Map.of(
"liquidity", new String[]{"Finished stock is unsold going into the lean quarter.", "तैयार माल बिका नहीं और सुस्त तिमाही आ रही है।"},
"behavioural", new String[]{"Top buyer accounts for 68% of all receipts.", "कुल आमदनी का 68% सिर्फ़ एक खरीदार से आता है।"},
"trendVolatility", new String[]{"Receipts concentrated in two months of the year.", "साल की कमाई सिर्फ़ दो महीनों में सिमटी है।"},
"obligationLoad", new String[]{"Repayment schedule ignores the seasonal lull.", "किस्त का समय सुस्त सीज़न का ध्यान नहीं रखता।"},
"externalShock", new String[]{"Exhibition calendar thinner than last year.", "पिछले साल से कम प्रदर्शनियाँ लगी हैं।"}),
"RURAL_RETAIL", Map.of(
"liquidity", new String[]{"Credit sales to customers up 31% this quarter.", "इस तिमाही उधार बिक्री 31% बढ़ी है।"},
"behavioural", new String[]{"Daily UPI receipt count down for six straight weeks.", "छह हफ़्तों से रोज़ के UPI लेन-देन घट रहे हैं।"},
"trendVolatility", new String[]{"Footfall dropping since the new road closure.", "रास्ता बंद होने से ग्राहक कम आ रहे हैं।"},
"obligationLoad", new String[]{"Stock restocking clashes with the instalment date.", "माल भरने और किस्त की तारीख़ एक साथ पड़ रही है।"},
"externalShock", new String[]{"Local market disruption flagged in the block.", "ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।"}));
/** Driver key → Hindi label, in the order the console renders them. */
static final String[][] DRIVERS = {
{"liquidity", "Liquidity", "नकदी"},
{"behavioural", "Behavioural (UPI)", "लेन-देन व्यवहार"},
{"trendVolatility", "Trend & volatility", "उतार-चढ़ाव"},
{"obligationLoad", "Obligation load", "किस्त का बोझ"},
{"externalShock", "External shock", "बाहरी झटका"},
};
static final String[] MONTHS_EN = {
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"};
static final String[] MONTHS_HI = {
"जनवरी", "फ़रवरी", "मार्च", "अप्रैल", "मई", "जून",
"जुलाई", "अगस्त", "सितंबर", "अक्टूबर", "नवंबर", "दिसंबर"};
}
@@ -0,0 +1,661 @@
package com.lincfusion.udyamlinc.seed;
import com.lincfusion.udyamlinc.auth.UdyamUser;
import com.lincfusion.udyamlinc.auth.UdyamUserRepository;
import com.lincfusion.udyamlinc.enterprise.Enterprise;
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
import com.lincfusion.udyamlinc.enterprise.LedgerEntry;
import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository;
import com.lincfusion.udyamlinc.enterprise.LoanRecord;
import com.lincfusion.udyamlinc.enterprise.LoanRecordRepository;
import com.lincfusion.udyamlinc.features.FeatureCatalog;
import com.lincfusion.udyamlinc.master.*;
import com.lincfusion.udyamlinc.officer.FieldVisit;
import com.lincfusion.udyamlinc.officer.FieldVisitRepository;
import com.lincfusion.udyamlinc.officer.PlaybookCatalog;
import com.lincfusion.udyamlinc.risk.*;
import com.lincfusion.udyamlinc.rules.TripWireRule;
import com.lincfusion.udyamlinc.rules.TripWireRuleRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
/**
* Seeds the demo portfolio on first boot. Idempotent: every step checks before
* it writes, so restarting the service does not duplicate anything.
*
* <p>The portfolio reproduces the submitted deck exactly — 142 enterprises with
* a 9 / 31 / 102 band split, and the six enterprises named on the wireframe slide
* pinned to their published scores. Figures are deterministic, seeded off the
* enterprise id, so a demo shows the same numbers every time.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class UdyamDataSeeder implements CommandLineRunner {
/** Initial credentials for seeded logins. */
public static final String DEFAULT_OFFICER_PASSWORD = "Udyam@123";
public static final String DEFAULT_ENTERPRISE_PIN = "1234";
private static final int TOTAL = 142;
private static final Map<String, Integer> TARGET = Map.of("RED", 9, "WATCH", 31, "STANDARD", 102);
private static final String MODEL_VERSION = "rule-v0.9.3";
private final DistrictRepository districtRepository;
private final CommodityRepository commodityRepository;
private final MarketRepository marketRepository;
private final SectorRepository sectorRepository;
private final SectorCommodityRepository sectorCommodityRepository;
private final RoleRepository roleRepository;
private final EnterpriseRepository enterpriseRepository;
private final LedgerEntryRepository ledgerRepository;
private final LoanRecordRepository loanRecordRepository;
private final FeatureCatalog featureCatalog;
private final ScoringRunRepository runRepository;
private final ForecastPointRepository pointRepository;
private final StressScoreRepository scoreRepository;
private final ReasonCodeRepository reasonRepository;
private final RiskAlertRepository alertRepository;
private final FieldVisitRepository visitRepository;
private final TripWireRuleRepository ruleRepository;
private final UdyamUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Value("${udyam.seed.enabled:true}")
private boolean seedEnabled;
@Value("${udyam.risk.red-threshold:65}")
private int redThreshold;
@Value("${udyam.risk.watch-threshold:35}")
private int watchThreshold;
@Value("${udyam.forecast.horizon-months:6}")
private int horizon;
@Override
@Transactional
public void run(String... args) {
if (!seedEnabled) {
log.info("Seeding disabled (udyam.seed.enabled=false)");
return;
}
seedRoles();
seedDistricts();
seedCommodities();
seedMarkets();
seedSectors();
seedSectorCommodities();
seedTripWireRules();
featureCatalog.seed();
seedOfficers();
seedPortfolio();
log.info("UdyamLinc seed complete — {} enterprises on book", enterpriseRepository.count());
}
// ── masters ─────────────────────────────────────────────
private void seedRoles() {
if (roleRepository.count() > 0) return;
roleRepository.saveAll(List.of(
Role.builder().code("FIELD_OFFICER").name("Field Officer").systemRole(true)
.permissions("PORTFOLIO:VIEW,ALERT:VIEW,ALERT:ACT,VISIT:VIEW,VISIT:LOG,REPORT:VIEW").build(),
Role.builder().code("SUPERVISOR").name("Supervisor").systemRole(true)
.permissions("PORTFOLIO:VIEW,ALERT:VIEW,ALERT:ACT,VISIT:VIEW,VISIT:LOG,REPORT:VIEW,CONFIG:MANAGE,RULE:MANAGE").build(),
Role.builder().code("OWNER").name("Enterprise Owner").systemRole(true)
.permissions("APP:VIEW,LEDGER:WRITE,CONSENT:MANAGE").build()));
log.info("Seeded {} roles", roleRepository.count());
}
/** The pilot's four districts, with centroids for picking the climate grid cell. */
private void seedDistricts() {
if (districtRepository.count() > 0) return;
districtRepository.saveAll(List.of(
district("SEHORE", "Sehore", "सीहोर", 23.2033, 77.0856),
district("VIDISHA", "Vidisha", "विदिशा", 23.5251, 77.8081),
district("RAISEN", "Raisen", "रायसेन", 23.3300, 77.7800),
district("BHOPAL", "Bhopal", "भोपाल", 23.2599, 77.4126)));
log.info("Seeded {} districts", districtRepository.count());
}
private District district(String code, String name, String nameHi, double lat, double lon) {
return District.builder().code(code).name(name).nameHi(nameHi)
.state("Madhya Pradesh").latitude(lat).longitude(lon).active(true).build();
}
/**
* Commodities the sector packs track. {@code sourceCode} is the name the
* upstream feed uses, which rarely matches ours.
*/
private void seedCommodities() {
if (commodityRepository.count() > 0) return;
commodityRepository.saveAll(List.of(
commodity("MAIZE", "Maize", "मक्का", "AGMARKNET", "Maize", "QUINTAL"),
commodity("SOYA", "Soyabean", "सोयाबीन", "AGMARKNET", "Soyabean", "QUINTAL"),
commodity("WHEAT", "Wheat", "गेहूँ", "AGMARKNET", "Wheat", "QUINTAL"),
commodity("GRAM", "Gram", "चना", "AGMARKNET", "Bengal Gram(Gram)(Whole)", "QUINTAL"),
commodity("EGG", "Egg", "अंडा", "NECC", "Bhopal", "HUNDRED"),
commodity("MILK", "Milk", "दूध", "DAIRY_FEDERATION", null, "LITRE"),
commodity("FODDER", "Green fodder", "हरा चारा", "AGMARKNET", "Green Fodder", "QUINTAL")));
log.info("Seeded {} commodities", commodityRepository.count());
}
private Commodity commodity(String code, String name, String nameHi, String source, String sourceCode, String unit) {
return Commodity.builder().code(code).name(name).nameHi(nameHi)
.source(source).sourceCode(sourceCode).unit(unit).active(true).build();
}
private void seedMarkets() {
if (marketRepository.count() > 0) return;
districtRepository.findAll().forEach(d -> marketRepository.save(Market.builder()
.code(d.getCode() + "-MANDI")
.name(d.getName() + " Krishi Upaj Mandi")
.sourceName(d.getName())
.districtId(d.getId())
.primaryMarket(true)
.active(true)
.build()));
log.info("Seeded {} markets", marketRepository.count());
}
/** The five risk packs. Weights are basis points and sum to 10000. */
private void seedSectors() {
if (sectorRepository.count() > 0) return;
sectorRepository.saveAll(List.of(
sector("DAIRY", "Dairy", "डेयरी", "Dairy", 2800, 2000, 1800, 1900, 1500),
sector("POULTRY", "Poultry", "मुर्गी पालन", "Poultry", 3000, 2200, 1700, 1400, 1700),
sector("FOOD_PROC", "Food processing", "खाद्य प्रसंस्करण", "Food proc.", 2600, 2200, 2000, 1800, 1400),
sector("HANDICRAFTS", "Handicrafts", "हस्तशिल्प", "Handicrafts", 2400, 1800, 2000, 1600, 2200),
sector("RURAL_RETAIL", "Rural retail", "ग्रामीण दुकान", "Rural retail", 3000, 2400, 1800, 1800, 1000)));
log.info("Seeded {} sector packs", sectorRepository.count());
}
private Sector sector(String code, String label, String labelHi, String shortLabel,
int liq, int beh, int trend, int obl, int shock) {
return Sector.builder().code(code).label(label).labelHi(labelHi).shortLabel(shortLabel)
.wLiquidity(liq).wBehavioural(beh).wTrendVolatility(trend)
.wObligationLoad(obl).wExternalShock(shock).active(true).build();
}
/**
* What each pack buys and sells. The INPUT / OUTPUT split is what makes
* margin squeeze computable once the ingestion layer lands.
*/
private void seedSectorCommodities() {
if (sectorCommodityRepository.count() > 0) return;
link("POULTRY", "MAIZE", "INPUT", 6000);
link("POULTRY", "SOYA", "INPUT", 4000);
link("POULTRY", "EGG", "OUTPUT", 10000);
link("DAIRY", "FODDER", "INPUT", 7000);
link("DAIRY", "MAIZE", "INPUT", 3000);
link("DAIRY", "MILK", "OUTPUT", 10000);
link("FOOD_PROC", "WHEAT", "INPUT", 5000);
link("FOOD_PROC", "GRAM", "INPUT", 5000);
link("RURAL_RETAIL", "WHEAT", "INPUT", 10000);
log.info("Seeded {} sector-commodity links", sectorCommodityRepository.count());
}
private void link(String sectorCode, String commodityCode, String role, int weight) {
Long sectorId = sectorRepository.findByCode(sectorCode).map(Sector::getId).orElse(null);
Long commodityId = commodityRepository.findByCode(commodityCode).map(Commodity::getId).orElse(null);
if (sectorId == null || commodityId == null) return;
sectorCommodityRepository.save(SectorCommodity.builder()
.sectorId(sectorId).commodityId(commodityId).role(role).weight(weight).build());
}
/**
* The five deterministic trip-wires. Expressions are SpEL over the feature
* snapshot; the evaluator that runs them arrives with the feature store.
*/
private void seedTripWireRules() {
if (ruleRepository.count() > 0) return;
ruleRepository.saveAll(List.of(
rule("TW-01", "Projected DSCR below 1.0", "किस्त चुकाने की क्षमता 1.0 से नीचे",
"dscr_p10 < 1.0", "DSCR(P10) < 1.00", "RED", "dscr_p10", 7),
rule("TW-02", "UPI inflow drop over 8 weeks", "आठ हफ़्तों में UPI आमदनी में गिरावट",
"upi_inflow_change < -0.20", "Δ inflow < −20%", "RED", "upi_inflow_change", 10),
rule("TW-03", "Ledger silent for 14 days", "14 दिन से हिसाब दर्ज नहीं",
"days_since_last_entry >= 14", "no entry ≥ 14 days", "WATCH", "days_since_last_entry", 3),
rule("TW-04", "Instalment share of inflow above 40%", "किस्त आमदनी का 40% से ज़्यादा",
"emi_to_inflow > 0.40", "EMI / inflow > 40%", "WATCH", "emi_to_inflow", 30),
rule("TW-05", "Climate or mandi shock in block", "ब्लॉक में मौसम या मंडी का झटका",
"price_z_score > 1.5 or heat_anomaly > 1.5", "anomaly z-score > 1.5", "WATCH",
"price_z_score,heat_anomaly", 14)));
log.info("Seeded {} trip-wire rules", ruleRepository.count());
}
private TripWireRule rule(String code, String label, String labelHi, String expression,
String thresholdText, String severity, String features, int staleness) {
return TripWireRule.builder()
.code(code).label(label).labelHi(labelHi).expression(expression)
.thresholdText(thresholdText).severity(severity)
.requiredFeatures(features).maxStalenessDays(staleness)
.active(true).version(1).updatedBy("seed").updatedAt(LocalDateTime.now())
.build();
}
private void seedOfficers() {
if (userRepository.findByUsernameIgnoreCase("aditya.vyas").isPresent()) return;
String blocks = "Sehore,Vidisha,Raisen,Bhopal";
userRepository.saveAll(List.of(
UdyamUser.builder().username("aditya.vyas").fullName("Aditya Vyas")
.password(passwordEncoder.encode(DEFAULT_OFFICER_PASSWORD))
.role("FIELD_OFFICER").scope("OFFICER").blocks(blocks)
.branch("Sehore & Vidisha blocks").active(true).build(),
UdyamUser.builder().username("supervisor").fullName("Branch Supervisor")
.password(passwordEncoder.encode(DEFAULT_OFFICER_PASSWORD))
.role("SUPERVISOR").scope("OFFICER").blocks(blocks)
.branch("Bhopal region").active(true).build()));
log.info("Seeded officer logins (password {})", DEFAULT_OFFICER_PASSWORD);
}
// ── portfolio ───────────────────────────────────────────
/** The six enterprises printed on the deck's wireframe slide. */
private record Pinned(String id, String name, String nameHi, String sector, String block, int score) {
}
private static final List<Pinned> PINNED = List.of(
new Pinned("MSME-4417", "Sehore Poultry Farm", "सीहोर पोल्ट्री फ़ार्म", "POULTRY", "Sehore", 79),
new Pinned("MSME-3902", "Sharda Dairy Collective", "शारदा डेयरी समूह", "DAIRY", "Vidisha", 71),
new Pinned("MSME-5120", "Anand Food Products", "आनंद फ़ूड प्रोडक्ट्स", "FOOD_PROC", "Raisen", 58),
new Pinned("MSME-2861", "Gramin Kirana Store", "ग्रामीण किराना स्टोर", "RURAL_RETAIL", "Sehore", 41),
new Pinned("MSME-6033", "Meera Handicrafts", "मीरा हस्तशिल्प", "HANDICRAFTS", "Bhopal", 22),
new Pinned("MSME-4488", "Krishna Dairy Unit", "कृष्णा डेयरी यूनिट", "DAIRY", "Vidisha", 18));
/** Slide-6 driver weights for the headline enterprise. */
private static final Map<String, int[]> PINNED_SUBSCORES =
Map.of("MSME-4417", new int[]{26, 19, 14, 11, 9});
/** Deck figure: a ₹12,400 gap three months out for MSME-4417. */
private static final int PINNED_BREACH_HORIZON = 3;
private static final BigDecimal PINNED_SHORTFALL = new BigDecimal("12400");
private void seedPortfolio() {
if (enterpriseRepository.count() > 0) return;
Map<String, Sector> sectors = new HashMap<>();
sectorRepository.findAll().forEach(s -> sectors.put(s.getCode(), s));
Map<String, District> districts = new HashMap<>();
districtRepository.findAll().forEach(d -> districts.put(d.getName(), d));
List<String> bandPlan = buildBandPlan();
List<String> sectorCodes = List.of("DAIRY", "POULTRY", "FOOD_PROC", "HANDICRAFTS", "RURAL_RETAIL");
List<String> blocks = List.of("Sehore", "Vidisha", "Raisen", "Bhopal");
for (Pinned p : PINNED) {
save(build(p.id(), p.name(), p.nameHi(), p.sector(), p.block(), p.score(), sectors, districts));
}
for (int i = 0; i < TOTAL - PINNED.size(); i++) {
String enterpriseId = "MSME-" + (1000 + ((i * 37 + 113) % 8000));
Random r = seeded(enterpriseId);
String sectorCode = sectorCodes.get(r.nextInt(sectorCodes.size()));
String block = blocks.get(r.nextInt(blocks.size()));
String band = bandPlan.get(i);
int score = scoreForBand(r, band);
int n = r.nextInt(SeedText.PREFIX.length);
List<String> suffix = SeedText.SUFFIX.get(sectorCode);
List<String> suffixHi = SeedText.SUFFIX_HI.get(sectorCode);
int s = r.nextInt(suffix.size());
save(build(enterpriseId,
SeedText.PREFIX[n] + " " + suffix.get(s),
SeedText.PREFIX_HI[n] + " " + suffixHi.get(s),
sectorCode, block, score, sectors, districts));
}
}
/**
* A band assignment that always totals 9 / 31 / 102 once the pinned six are
* accounted for. Shuffled with a fixed seed so the mix is stable across boots.
*/
private List<String> buildBandPlan() {
Map<String, Integer> pinnedCounts = new HashMap<>();
for (Pinned p : PINNED) {
pinnedCounts.merge(bandOf(p.score()), 1, Integer::sum);
}
List<String> plan = new ArrayList<>();
TARGET.forEach((band, target) -> {
int remaining = target - pinnedCounts.getOrDefault(band, 0);
for (int i = 0; i < remaining; i++) plan.add(band);
});
Collections.shuffle(plan, new Random(20260804L));
return plan;
}
private Enterprise build(String enterpriseId, String name, String nameHi, String sectorCode, String block,
int score, Map<String, Sector> sectors, Map<String, District> districts) {
Random r = seeded(enterpriseId);
District d = districts.get(block);
return Enterprise.builder()
.enterpriseId(enterpriseId)
.name(name)
.nameHi(nameHi)
.sectorId(sectors.get(sectorCode).getId())
.districtId(d.getId())
.block(block)
// Jittered around the district centroid — a premises location, not a person.
.latitude(d.getLatitude() + (r.nextDouble() - 0.5) * 0.25)
.longitude(d.getLongitude() + (r.nextDouble() - 0.5) * 0.25)
.marketId(marketRepository.findFirstByDistrictIdAndPrimaryMarketTrue(d.getId())
.map(Market::getId).orElse(null))
.ownerCount(1 + r.nextInt(4))
.vintageMonths(8 + r.nextInt(112))
.loanOutstanding(BigDecimal.valueOf((4 + r.nextInt(37)) * 10_000L))
.monthlyInstalment(BigDecimal.valueOf((16 + r.nextInt(95)) * 250L))
.score(score)
.band(bandOf(score))
.consentActive(true)
.lastScoredAt(LocalDateTime.now().minusMinutes(12))
.build();
}
/** Persist an enterprise together with its run, forecast, score, reasons and alert. */
private void save(Enterprise e) {
Enterprise saved = enterpriseRepository.save(e);
String sectorCode = sectorRepository.findById(saved.getSectorId()).map(Sector::getCode).orElse("RURAL_RETAIL");
ScoringRun run = runRepository.save(ScoringRun.builder()
.enterpriseId(saved.getEnterpriseId())
.engine("RULE")
.modelVersion(MODEL_VERSION)
.source("SERVER")
.asOfDate(LocalDate.now())
.horizonMonths(horizon)
.enterpriseCount(1)
.status("COMPLETED")
.build());
List<ForecastPoint> points = buildForecast(saved, run.getId());
pointRepository.saveAll(points);
int[] subs = subScores(saved, sectorCode);
scoreRepository.save(StressScore.builder()
.runId(run.getId())
.enterpriseId(saved.getEnterpriseId())
.score(saved.getScore())
.band(saved.getBand())
.engine("RULE")
.confidence(62)
.liquidity(subs[0]).behavioural(subs[1]).trendVolatility(subs[2])
.obligationLoad(subs[3]).externalShock(subs[4])
.build());
List<ReasonCode> reasons = buildReasons(saved, sectorCode, subs, run.getId());
reasonRepository.saveAll(reasons);
seedLedger(saved);
seedLoans(saved);
if (!"STANDARD".equals(saved.getBand())) {
seedAlertAndVisit(saved, run.getId(), points, reasons.get(0));
}
}
/**
* The institutional side of the record — what a nightly CBS extract would
* carry. DPD tracks the band, because in the demo the band is the ground
* truth; in production the causality runs the other way.
*/
private void seedLoans(Enterprise e) {
if (loanRecordRepository.countByEnterpriseId(e.getEnterpriseId()) > 0) return;
Random r = seeded(e.getEnterpriseId() + ":loan");
int dpd = switch (e.getBand()) {
case "RED" -> 31 + r.nextInt(60);
case "WATCH" -> 4 + r.nextInt(28);
default -> r.nextInt(4);
};
LocalDate asOf = LocalDate.now().withDayOfMonth(1).minusDays(1);
loanRecordRepository.save(LoanRecord.builder()
.accountRef(e.getEnterpriseId() + "-LN1")
.enterpriseId(e.getEnterpriseId())
.product(r.nextBoolean() ? "TERM_LOAN" : "WORKING_CAPITAL")
.sanctionedAmount(e.getLoanOutstanding().multiply(BigDecimal.valueOf(1.4))
.setScale(2, RoundingMode.HALF_UP))
.outstandingAmount(e.getLoanOutstanding())
.instalmentAmount(e.getMonthlyInstalment())
.nextDueDate(LocalDate.now().withDayOfMonth(Math.min(28, 5 + r.nextInt(20))).plusMonths(1))
.lastPaidDate(LocalDate.now().minusDays(dpd + 1L))
.dpd(dpd)
.bucket(bucketOf(dpd))
.closed(false)
.asOfDate(asOf)
.build());
}
private String bucketOf(int dpd) {
if (dpd == 0) return "0";
if (dpd <= 30) return "1-30";
if (dpd <= 60) return "31-60";
if (dpd <= 90) return "61-90";
return "90+";
}
/**
* Six months of P10 / P50 / P90 against the obligation line. P50 drifts by
* band; the fan widens with horizon because uncertainty compounds.
*/
private List<ForecastPoint> buildForecast(Enterprise e, Long runId) {
Random r = seeded(e.getEnterpriseId() + ":fc");
double base = 18_000 + (100 - e.getScore()) * 420.0 + (r.nextInt(4500) - 2000);
double drift = switch (e.getBand()) {
case "RED" -> -0.14;
case "WATCH" -> -0.05;
default -> 0.035;
};
double fixed = e.getMonthlyInstalment().doubleValue() + (8 + r.nextInt(15)) * 400.0;
boolean pin = PINNED_SUBSCORES.containsKey(e.getEnterpriseId());
List<ForecastPoint> points = new ArrayList<>();
LocalDate month = LocalDate.now().withDayOfMonth(1);
for (int h = 1; h <= horizon; h++) {
double centre = base * (1 + drift * h) + (r.nextDouble() - 0.5) * 1800;
double spread = centre * (0.16 + (h - 1) * 0.045) + 900;
double obligation = fixed + (h - 1) * 120;
double p10 = centre - spread;
double p50 = centre;
double p90 = centre + spread * 0.85;
// Pin the deck's headline enterprise to a ₹12,400 gap in month three,
// and keep the earlier months clear so the first breach lands there.
if (pin) {
if (h == PINNED_BREACH_HORIZON) {
p10 = obligation - PINNED_SHORTFALL.doubleValue();
p50 = Math.max(p50, p10 + 4_000);
p90 = Math.max(p90, p50 + 5_000);
} else if (h < PINNED_BREACH_HORIZON) {
p10 = Math.max(p10, obligation + 1_500);
p50 = Math.max(p50, p10 + 3_000);
p90 = Math.max(p90, p50 + 4_000);
}
}
LocalDate m = month.plusMonths(h - 1L);
points.add(ForecastPoint.builder()
.runId(runId)
.enterpriseId(e.getEnterpriseId())
.horizon(h)
.month(String.format("%d-%02d", m.getYear(), m.getMonthValue()))
.p10(money(p10)).p50(money(p50)).p90(money(p90))
.obligation(money(obligation))
.breach(p10 < obligation)
.build());
}
return points;
}
/** Split the composite across the five families, weighted by the sector pack. */
private int[] subScores(Enterprise e, String sectorCode) {
int[] pinned = PINNED_SUBSCORES.get(e.getEnterpriseId());
if (pinned != null) return pinned;
Sector s = sectorRepository.findByCode(sectorCode).orElseThrow();
Random r = seeded(e.getEnterpriseId() + ":sub");
int[] weights = {s.getWLiquidity(), s.getWBehavioural(), s.getWTrendVolatility(),
s.getWObligationLoad(), s.getWExternalShock()};
int[] out = new int[5];
for (int i = 0; i < 5; i++) {
out[i] = (int) Math.round(e.getScore() * (weights[i] / 10_000.0) * (0.8 + r.nextDouble() * 0.5));
}
return out;
}
/** Top-three drivers as sentences — the stand-in for SHAP output. */
private List<ReasonCode> buildReasons(Enterprise e, String sectorCode, int[] subs, Long runId) {
Map<String, String[]> text = SeedText.REASONS.get(sectorCode);
List<ReasonCode> all = new ArrayList<>();
for (int i = 0; i < SeedText.DRIVERS.length; i++) {
String key = SeedText.DRIVERS[i][0];
String[] sentence = text.get(key);
all.add(ReasonCode.builder()
.runId(runId)
.enterpriseId(e.getEnterpriseId())
.driver(SeedText.DRIVERS[i][1])
.driverHi(SeedText.DRIVERS[i][2])
.weight(subs[i])
.sentenceEn(sentence[0])
.sentenceHi(sentence[1])
.build());
}
all.sort((a, b) -> b.getWeight() - a.getWeight());
List<ReasonCode> top = all.subList(0, 3);
for (int i = 0; i < top.size(); i++) top.get(i).setRank(i + 1);
return top;
}
private void seedAlertAndVisit(Enterprise e, Long runId, List<ForecastPoint> points, ReasonCode top) {
ForecastPoint breach = points.stream().filter(p -> Boolean.TRUE.equals(p.getBreach())).findFirst().orElse(null);
Random r = seeded(e.getEnterpriseId() + ":alert");
String monthEn = null;
String monthHi = null;
if (breach != null) {
int m = Integer.parseInt(breach.getMonth().substring(5)) - 1;
monthEn = SeedText.MONTHS_EN[m];
monthHi = SeedText.MONTHS_HI[m];
}
RiskAlert alert = alertRepository.save(RiskAlert.builder()
.alertId("ALT-" + (4200 + alertRepository.count()))
.enterpriseId(e.getEnterpriseId())
.runId(runId)
.band(e.getBand())
.score(e.getScore())
.origin(e.getScore() >= 75 ? "TRIPWIRE" : "MODEL")
.title(breach == null ? "Stress score above watch threshold"
: "Cash shortfall projected by " + monthEn)
.titleHi(breach == null ? "जोखिम स्कोर निगरानी सीमा से ऊपर"
: monthHi + " तक पैसा कम पड़ सकता है")
.reasonEn(top.getSentenceEn())
.reasonHi(top.getSentenceHi())
.shortfall(breach == null ? BigDecimal.ZERO : breach.getObligation().subtract(breach.getP10()))
.breachMonth(breach == null ? null : breach.getMonth())
.state("RED".equals(e.getBand()) ? "OPEN" : (r.nextBoolean() ? "OPEN" : "ACKNOWLEDGED"))
.raisedAt(LocalDateTime.now().minusDays(r.nextInt(21)))
.build());
List<PlaybookCatalog.Action> options = PlaybookCatalog.forBand(e.getBand());
PlaybookCatalog.Action action = options.isEmpty()
? PlaybookCatalog.ACTIONS.get(0)
: options.get(r.nextInt(options.size()));
visitRepository.save(FieldVisit.builder()
.visitId("FV-" + (1800 + visitRepository.count()))
.enterpriseId(e.getEnterpriseId())
.enterpriseName(e.getName())
.block(e.getBlock())
.actionCode(action.code())
.action(action.label())
.actionHi(action.labelHi())
.alertId(alert.getId())
.priority("RED".equals(e.getBand()) ? 1 : 2)
.dueDate(LocalDate.now().plusDays(r.nextInt(20) - 6L))
.state("PLANNED")
.officer("Aditya Vyas")
.build());
}
/**
* Thirty days of ledger history so the app is never empty, plus an app login
* for the enterprise. Only for the pinned six — generating 142 × ~60 entries
* would slow first boot for no demo value.
*/
private void seedLedger(Enterprise e) {
boolean pinned = PINNED.stream().anyMatch(p -> p.id().equals(e.getEnterpriseId()));
if (!pinned) return;
if (userRepository.findByEnterpriseIdAndScopeIgnoreCase(e.getEnterpriseId(), "ENTERPRISE").isEmpty()) {
userRepository.save(UdyamUser.builder()
.username(e.getEnterpriseId().toLowerCase())
.fullName(e.getName())
.password(passwordEncoder.encode(DEFAULT_ENTERPRISE_PIN))
.role("OWNER").scope("ENTERPRISE").enterpriseId(e.getEnterpriseId())
.active(true).build());
}
Random r = seeded(e.getEnterpriseId() + ":ledger");
String[] inCats = {"SALE", "UPI", "RECOVERY"};
String[] outCats = {"INPUT", "WAGES", "TRANSPORT", "EMI"};
List<LedgerEntry> entries = new ArrayList<>();
for (int d = 30; d >= 0; d--) {
int count = 1 + r.nextInt(3);
for (int i = 0; i < count; i++) {
boolean in = r.nextDouble() > 0.45;
entries.add(LedgerEntry.builder()
.clientId("seed-" + e.getEnterpriseId() + "-" + d + "-" + i)
.enterpriseId(e.getEnterpriseId())
.kind(in ? "IN" : "OUT")
.amount(BigDecimal.valueOf((in ? 6 + r.nextInt(47) : 4 + r.nextInt(35)) * 100L))
.category(in ? inCats[r.nextInt(inCats.length)] : outCats[r.nextInt(outCats.length)])
.entryDate(LocalDate.now().minusDays(d))
.syncedAt(LocalDateTime.now())
.build());
}
}
ledgerRepository.saveAll(entries);
}
// ── helpers ─────────────────────────────────────────────
private String bandOf(int score) {
if (score >= redThreshold) return "RED";
if (score >= watchThreshold) return "WATCH";
return "STANDARD";
}
/**
* Generated scores stay below the headline enterprise's 79, so Sehore Poultry
* Farm leads the dashboard exactly as it does on the deck's wireframe.
*/
private int scoreForBand(Random r, String band) {
return switch (band) {
case "RED" -> 66 + r.nextInt(13);
case "WATCH" -> 36 + r.nextInt(29);
default -> 8 + r.nextInt(27);
};
}
/** Deterministic per-enterprise randomness, so a demo shows the same numbers every run. */
private Random seeded(String key) {
return new Random(key.hashCode());
}
private BigDecimal money(double v) {
return BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP);
}
}