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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user