diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureCatalog.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureCatalog.java index 3de118f..4f6c044 100644 --- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureCatalog.java +++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureCatalog.java @@ -43,6 +43,17 @@ public class FeatureCatalog { public static final String UPI_INFLOW_CHANGE = "upi_inflow_change"; public static final String FEED_STALENESS_DAYS = "feed_staleness_days"; public static final String DSCR_P10 = "dscr_p10"; + + /** + * The same ratio at the central estimate. + * + *

Exists because P10 and P50 are different claims and were being treated + * as one. A P10 breach says the downside case misses; a P50 breach says the + * expectation misses. Firing a single red-severity rule on the first collapsed + * the watch band — every enterprise whose downside was short became red, and a + * demo portfolio of a hundred and ten held three watches. + */ + public static final String DSCR_P50 = "dscr_p50"; public static final String DAYS_SINCE_LAST_ENTRY = "days_since_last_entry"; public static final String EMI_TO_INFLOW = "emi_to_inflow"; @@ -50,7 +61,7 @@ public class FeatureCatalog { FEED_COST_CHANGE, OUTPUT_PRICE_CHANGE, MARGIN_SQUEEZE, PRICE_Z_SCORE, RAINFALL_ANOMALY, HEAT_ANOMALY, CASH_FLOW_CHANGE, EMI_DELAY, INVENTORY_DAYS, UPI_INFLOW_CHANGE, FEED_STALENESS_DAYS, - DSCR_P10, DAYS_SINCE_LAST_ENTRY, EMI_TO_INFLOW); + DSCR_P10, DSCR_P50, DAYS_SINCE_LAST_ENTRY, EMI_TO_INFLOW); // ── families ──────────────────────────────────────────── @@ -68,6 +79,13 @@ public class FeatureCatalog { public static final String NO_CBS = "NO_CBS"; public static final String NO_CONSENT = "NO_CONSENT"; public static final String NO_FORECAST = "NO_FORECAST"; + + /** + * A forecast exists but had no view of margin at all — no output price and no + * input cost — so its band sits at the widest this method produces. The P10 is + * arithmetic rather than evidence, and TW-01 declines to fire on it. + */ + public static final String NO_MARGIN_VIEW = "NO_MARGIN_VIEW"; public static final String INSUFFICIENT_HISTORY = "INSUFFICIENT_HISTORY"; private final FeatureDefinitionRepository repository; @@ -107,6 +125,8 @@ public class FeatureCatalog { "HIGHER_IS_WORSE", "Age of the newest market observation for the district — guards every price-derived feature above"), def(DSCR_P10, "DSCR at P10", OBLIGATION_LOAD, "RATIO", "FORECAST", 7, "LOWER_IS_WORSE", "Worst month of the horizon: min(p10 / obligation) over the latest scoring run — the downside case failing to cover what is due"), + def(DSCR_P50, "DSCR at P50", OBLIGATION_LOAD, "RATIO", "FORECAST", 7, + "LOWER_IS_WORSE", "Worst month of the horizon: min(p50 / obligation) over the latest scoring run — the central case failing to cover what is due"), def(DAYS_SINCE_LAST_ENTRY, "Days since last ledger entry", BEHAVIOURAL, "DAYS", "LEDGER", 3, "HIGHER_IS_WORSE", "Days between the newest ledger entry date and the scoring date; silence is itself a signal"), def(EMI_TO_INFLOW, "Instalment share of inflow", OBLIGATION_LOAD, "RATIO", "CBS", 30, diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureEngineeringService.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureEngineeringService.java index 9ee1854..b8625ec 100644 --- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureEngineeringService.java +++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureEngineeringService.java @@ -72,6 +72,10 @@ public class FeatureEngineeringService { @Value("${udyam.forecast.trust-seeded:true}") private boolean trustSeededForecasts; + /** Furthest horizon {@code dscr_p10} — and so TW-01 — is allowed to act on. */ + @Value("${udyam.forecast.tripwire-horizon:3}") + private int tripwireHorizon; + // ── entry points ──────────────────────────────────────── /** One pass over the whole book. Consent-revoked enterprises are skipped, not computed. */ @@ -127,6 +131,7 @@ public class FeatureEngineeringService { features.put(FeatureCatalog.DAYS_SINCE_LAST_ENTRY, daysSinceLastEntry(enterprise.getEnterpriseId(), asOfDate)); features.put(FeatureCatalog.EMI_TO_INFLOW, emiToInflow(enterprise.getEnterpriseId(), asOfDate)); features.put(FeatureCatalog.DSCR_P10, dscrP10(enterprise.getEnterpriseId(), asOfDate)); + features.put(FeatureCatalog.DSCR_P50, dscrP50(enterprise.getEnterpriseId(), asOfDate)); return persist(enterprise.getEnterpriseId(), asOfDate, observedAsOf, scoringRunId, features); } @@ -371,7 +376,20 @@ public class FeatureEngineeringService { return newest .map(date -> { int days = staleness(date, asOfDate); - return Computed.of(BigDecimal.valueOf(days), days); + // Staleness zero, deliberately, even though the underlying + // entry is `days` old. + // + // Every other feature is attributed the age of the data behind + // it, and the rule engine refuses to fire a rule whose inputs + // are older than its tolerance. Applying that here inverts the + // one feature whose subject *is* absence: an enterprise silent + // for twenty days produces a twenty-day-old input, TW-03's + // three-day tolerance rejects it, and the rule built to detect + // a silent ledger is switched off by exactly the silence it + // was built to detect. The measurement itself is made fresh + // today from a complete read, so it is not stale — it is + // current, and it says nothing has arrived. + return Computed.of(BigDecimal.valueOf(days), 0); }) .orElseGet(() -> Computed.missing(FeatureCatalog.NO_LEDGER)); } @@ -462,6 +480,24 @@ public class FeatureEngineeringService { * produced the forecast an officer saw. */ private Computed dscrP10(String enterpriseId, LocalDate asOfDate) { + return dscr(enterpriseId, asOfDate, ForecastPoint::getP10); + } + + /** + * The central case against the same obligation. + * + *

Kept as its own feature rather than derived in a rule so that both + * quantiles are stored, auditable and visible on the snapshot. An alert that + * says "expected to fall short" and one that says "could fall short" are + * different claims to an officer knocking on a door, and the evidence for + * each has to survive independently of the rule that read it. + */ + private Computed dscrP50(String enterpriseId, LocalDate asOfDate) { + return dscr(enterpriseId, asOfDate, ForecastPoint::getP50); + } + + private Computed dscr(String enterpriseId, LocalDate asOfDate, + java.util.function.Function quantile) { List points = forecastPointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId); // Seeded rows carry no engine. Once a real forecast engine exists, a @@ -478,11 +514,31 @@ public class FeatureEngineeringService { .filter(Objects::nonNull).max(Long::compareTo).orElse(null); if (latestRun == null) return Computed.missing(FeatureCatalog.NO_FORECAST); + List latest = points.stream() + .filter(point -> latestRun.equals(point.getRunId())) + .toList(); + + // A forecast with no margin view at all carries the widest band the method + // produces, and √h drives it to the cap within four months — at which + // point P10 is a quarter of P50 for everyone, healthy or not, and TW-01 + // fires on the entire book. That is not early warning, it is the absence + // of information wearing its clothes. Withhold the number instead, the + // same way the rule engine withholds a rule whose inputs are stale. + boolean blind = !latest.isEmpty() && latest.stream() + .allMatch(point -> point.getMissingInputs() != null && point.getMissingInputs() >= 2); + if (blind) return Computed.missing(FeatureCatalog.NO_MARGIN_VIEW); + + // Worst case over the near horizon only. Beyond it the band is dominated + // by compounding uncertainty rather than by anything specific to this + // enterprise, so a month-six P10 separates nobody from anybody. The full + // six-month fan is still stored and still drawn on the forecast screen — + // this bounds what the trip-wire is allowed to act on, not what is shown. BigDecimal worst = null; - for (ForecastPoint point : points) { - if (!latestRun.equals(point.getRunId())) continue; - if (point.getP10() == null || point.getObligation() == null || point.getObligation().signum() == 0) continue; - BigDecimal ratio = point.getP10().divide(point.getObligation(), 4, RoundingMode.HALF_UP); + for (ForecastPoint point : latest) { + if (point.getHorizon() != null && point.getHorizon() > tripwireHorizon) continue; + BigDecimal value = quantile.apply(point); + if (value == null || point.getObligation() == null || point.getObligation().signum() == 0) continue; + BigDecimal ratio = value.divide(point.getObligation(), 4, RoundingMode.HALF_UP); if (worst == null || ratio.compareTo(worst) < 0) worst = ratio; } return worst == null ? Computed.missing(FeatureCatalog.NO_FORECAST) : Computed.of(worst, 0); diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshot.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshot.java index 305b51b..7e8705a 100644 --- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshot.java +++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshot.java @@ -90,6 +90,10 @@ public class FeatureSnapshot { @Column(precision = 16, scale = 4) private BigDecimal dscrP10; + /** The same, at the central estimate. Below 1.0 the expected case does not cover it either. */ + @Column(precision = 16, scale = 4) + private BigDecimal dscrP50; + @Column(precision = 16, scale = 4) private BigDecimal emiToInflow; @@ -136,6 +140,7 @@ public class FeatureSnapshot { map.put(FeatureCatalog.UPI_INFLOW_CHANGE, upiInflowChange); map.put(FeatureCatalog.FEED_STALENESS_DAYS, feedStalenessDays); map.put(FeatureCatalog.DSCR_P10, dscrP10); + map.put(FeatureCatalog.DSCR_P50, dscrP50); map.put(FeatureCatalog.DAYS_SINCE_LAST_ENTRY, daysSinceLastEntry); map.put(FeatureCatalog.EMI_TO_INFLOW, emiToInflow); return map; @@ -156,6 +161,7 @@ public class FeatureSnapshot { case FeatureCatalog.UPI_INFLOW_CHANGE -> upiInflowChange = value; case FeatureCatalog.FEED_STALENESS_DAYS -> feedStalenessDays = value; case FeatureCatalog.DSCR_P10 -> dscrP10 = value; + case FeatureCatalog.DSCR_P50 -> dscrP50 = value; case FeatureCatalog.DAYS_SINCE_LAST_ENTRY -> daysSinceLastEntry = value; case FeatureCatalog.EMI_TO_INFLOW -> emiToInflow = value; default -> throw new IllegalArgumentException("Unknown feature code: " + code); diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshotRepository.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshotRepository.java index b6b00aa..bb375e7 100644 --- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshotRepository.java +++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureSnapshotRepository.java @@ -17,4 +17,6 @@ public interface FeatureSnapshotRepository extends JpaRepository findByScoringRunId(Long scoringRunId); long countByAsOfDate(LocalDate asOfDate); + + List findByEnterpriseId(String enterpriseId); } diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureValueRepository.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureValueRepository.java index 4eec6ec..732bbb6 100644 --- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureValueRepository.java +++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureValueRepository.java @@ -31,4 +31,6 @@ public interface FeatureValueRepository extends JpaRepository findByEnterpriseId(String enterpriseId); } diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/DeterministicForecastStrategy.java b/src/main/java/com/lincfusion/udyamlinc/risk/DeterministicForecastStrategy.java index 57a115a..820ed17 100644 --- a/src/main/java/com/lincfusion/udyamlinc/risk/DeterministicForecastStrategy.java +++ b/src/main/java/com/lincfusion/udyamlinc/risk/DeterministicForecastStrategy.java @@ -145,6 +145,7 @@ public class DeterministicForecastStrategy implements ForecastStrategy { .breach(obligation.signum() > 0 && p10.compareTo(obligation) < 0) .engine(engine()) .modelVersion(version()) + .missingInputs(margin.missingInputs()) .build()); } return points; diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPoint.java b/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPoint.java index ae493d4..d6cff7c 100644 --- a/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPoint.java +++ b/src/main/java/com/lincfusion/udyamlinc/risk/ForecastPoint.java @@ -66,4 +66,16 @@ public class ForecastPoint { @Column(length = 32) private String modelVersion; + + /** + * How many margin inputs the path had to do without: 0 with a full margin + * view, 1 with input costs only, 2 with no view of margin at all. + * + *

Stored because the spread alone cannot be read backwards — a wide band + * on a healthy enterprise and a wide band on one nothing is known about look + * identical in {@code p10}, and only the second is a reason to withhold a + * trip-wire. TW-01 consults this before firing, the same way the rule engine + * consults staleness. + */ + private Integer missingInputs; } diff --git a/src/main/java/com/lincfusion/udyamlinc/risk/ScoringRunRepository.java b/src/main/java/com/lincfusion/udyamlinc/risk/ScoringRunRepository.java index 9f3c38f..bc47df0 100644 --- a/src/main/java/com/lincfusion/udyamlinc/risk/ScoringRunRepository.java +++ b/src/main/java/com/lincfusion/udyamlinc/risk/ScoringRunRepository.java @@ -10,4 +10,6 @@ public interface ScoringRunRepository extends JpaRepository { Optional findFirstByEnterpriseIdOrderByRunAtDesc(String enterpriseId); List findTop20ByOrderByRunAtDesc(); + + List findByEnterpriseId(String enterpriseId); } diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java b/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java index 1956b80..b3f6330 100644 --- a/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java +++ b/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java @@ -116,6 +116,22 @@ public class InstitutionSeeder { upsertInstitution("VSFB", "Vindhya Small Finance Bank", "विंध्य स्मॉल फाइनेंस बैंक", "SFB", "Vindhya SFB", "Madhya Pradesh"); + // One regional rural bank per state the portfolio now covers. Without a + // lender in its own state, an enterprise there would be assigned to a + // Madhya Pradesh branch by the fallback in assignPortfolio — and the + // national view would show a Bihar borrower banking in Sehore, which is + // the sort of detail that costs the whole screen its credibility. + upsertInstitution("MGB", "Maharashtra Gramin Bank", "महाराष्ट्र ग्रामीण बैंक", + "RRB", "Maharashtra Gramin", "Maharashtra"); + upsertInstitution("RMGB", "Rajasthan Marudhara Gramin Bank", "राजस्थान मरुधरा ग्रामीण बैंक", + "RRB", "Marudhara Gramin", "Rajasthan"); + upsertInstitution("DBGB", "Dakshin Bihar Gramin Bank", "दक्षिण बिहार ग्रामीण बैंक", + "RRB", "Dakshin Bihar Gramin", "Bihar"); + upsertInstitution("KGB", "Karnataka Gramin Bank", "कर्नाटक ग्रामीण बैंक", + "RRB", "Karnataka Gramin", "Karnataka"); + upsertInstitution("AGVB", "Assam Gramin Vikash Bank", "असम ग्रामीण विकास बैंक", + "RRB", "Assam Gramin", "Assam"); + return nabard; } @@ -141,7 +157,20 @@ public class InstitutionSeeder { new B("SEHDCCB", "SEH02", "Sehore Cooperative Branch", "Sehore", "Sehore Region"), new B("SEHDCCB", "RAI01", "Raisen Cooperative Branch", "Raisen", "Sehore Region"), new B("VSFB", "BPL01", "Bhopal City Branch", "Bhopal", "Bhopal Region"), - new B("VSFB", "RAI02", "Raisen Branch", "Raisen", "Bhopal Region")); + new B("VSFB", "RAI02", "Raisen Branch", "Raisen", "Bhopal Region"), + // One branch per district in the five states added with the + // national portfolio. Blocks match the district names the plan + // seeds, because assignPortfolio keys on the block. + new B("MGB", "NSK01", "Nashik Main Branch", "Nashik", "Nashik Region"), + new B("MGB", "AHM01", "Ahmednagar Main Branch", "Ahmednagar", "Nashik Region"), + new B("RMGB", "JAI01", "Jaipur Main Branch", "Jaipur", "Jaipur Region"), + new B("RMGB", "KOT01", "Kota Main Branch", "Kota", "Kota Region"), + new B("DBGB", "MUZ01", "Muzaffarpur Main Branch", "Muzaffarpur", "Tirhut Region"), + new B("DBGB", "PAT01", "Patna Main Branch", "Patna", "Patna Region"), + new B("KGB", "KOL01", "Kolar Main Branch", "Kolar", "Bengaluru Region"), + new B("KGB", "BEL01", "Belagavi Main Branch", "Belagavi", "Belagavi Region"), + new B("AGVB", "KAM01", "Kamrup Main Branch", "Kamrup", "Guwahati Region"), + new B("AGVB", "NAG01", "Nagaon Main Branch", "Nagaon", "Nagaon Region")); Map out = new LinkedHashMap<>(); for (B b : plan) { @@ -228,9 +257,24 @@ public class InstitutionSeeder { upsertUser("dccb.officer", "Sehore DCCB — Branch Officer", "BRANCH_MANAGER", "BANK", dccb.getId(), dccbSehore == null ? null : dccbSehore.getId(), "Sehore"); + // A head-office login for each of the five state banks. Without one, that + // bank's portfolio exists but nobody can sign in to it, so cross-tenant + // isolation across zones can only be described rather than shown. + seedStateBankAdmin("MGB", "mgb.admin", "Maharashtra Gramin — Head Office", "Nashik,Ahmednagar"); + seedStateBankAdmin("RMGB", "rmgb.admin", "Marudhara Gramin — Head Office", "Jaipur,Kota"); + seedStateBankAdmin("DBGB", "dbgb.admin", "Dakshin Bihar Gramin — Head Office", "Muzaffarpur,Patna"); + seedStateBankAdmin("KGB", "kgb.admin", "Karnataka Gramin — Head Office", "Kolar,Belagavi"); + seedStateBankAdmin("AGVB", "agvb.admin", "Assam Gramin Vikash — Head Office", "Kamrup,Nagaon"); + backfillLegacyUsers(mpgb, sehore); } + private void seedStateBankAdmin(String institutionCode, String username, String fullName, String blocks) { + Institution bank = institutionRepository.findByCode(institutionCode).orElse(null); + if (bank == null) return; + upsertUser(username, fullName, "BANK_ADMIN", "BANK", bank.getId(), null, blocks); + } + private void upsertUser(String username, String fullName, String role, String scope, Long institutionId, Long branchId, String blocks) { if (userRepository.findByUsernameIgnoreCase(username).isPresent()) return; diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPlan.java b/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPlan.java new file mode 100644 index 0000000..492beda --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPlan.java @@ -0,0 +1,309 @@ +package com.lincfusion.udyamlinc.seed; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * The demo portfolio's intended shape, as data rather than as a loop. + * + *

The plan is a pure function of the constants below — no database, no clock, + * no randomness — so the same build always describes the same portfolio. That is + * what lets the seeder treat it as a target to converge on: it can seed a fresh + * database, add what a half-seeded one is missing, and (when explicitly asked) + * remove what no longer belongs, all by comparing against this list. + * + *

Shape: six states, one per NABARD zone, each carrying three to four + * enterprises in every sector pack. Uniform on purpose. An earlier version put + * 142 enterprises in Madhya Pradesh and nothing anywhere else, which made the + * national view a map of one state and the zone rollups untestable — five of six + * zones had no portfolio to roll up. + */ +public final class PortfolioPlan { + + /** Sector packs, in the order the per-state quota below is read. */ + public static final List SECTORS = + List.of("DAIRY", "POULTRY", "FOOD_PROC", "HANDICRAFTS", "RURAL_RETAIL", "HORTICULTURE"); + + /** + * How many enterprises each sector pack carries per state, positionally + * against {@link #SECTORS}. Twenty-one per state. + * + *

Horticulture carries three rather than four for the same reason + * handicrafts and rural retail do — the four-deep packs are the ones whose + * margin squeeze is computable end to end, and a pack is only worth the extra + * enterprises if the feed can actually move them. + */ + private static final int[] PER_SECTOR = {4, 4, 4, 3, 3, 3}; + + /** + * Madhya Pradesh carries four in every pack rather than 4/4/4/3/3. + * + *

Not favouritism — arithmetic. It is the only state with four districts, + * and eighteen across four districts gives cells of five, five, four and + * four. The apex screens suppress any cell below {@code udyam.apex. + * min-cell-size} (five) to stop a borrower being re-identified from an + * aggregate, so two of Madhya Pradesh's districts would publish nothing. + * Twenty divides evenly into four fives. The suppression rule is left alone; + * the portfolio is shaped to stop tripping it needlessly. + */ + private static final int[] PER_SECTOR_MP = {4, 4, 4, 4, 4, 4}; + + private static int[] perSector(String state) { + return "Madhya Pradesh".equals(state) ? PER_SECTOR_MP : PER_SECTOR; + } + + /** + * Band mix per state. Chosen so every state has something for a field officer + * to act on — a state of nothing but STANDARD makes the early-warning screens + * look broken rather than calm. + */ + private static final int RED_PER_STATE = 2; + private static final int WATCH_PER_STATE = 5; + + /** A district as both we and Agmarknet name it. */ + public record DistrictSpec(String code, String name, String nameHi, String sourceName, + double latitude, double longitude) { + } + + /** A state, its zone-bearing name, and the districts the pilot covers in it. */ + public record Region(String state, String stateHi, List districts) { + } + + /** + * One state per zone. Districts are real, and {@code sourceName} carries the + * pre-rename spelling wherever Agmarknet still publishes under it — Nasik, + * Ahmadnagar, Belgaum. Getting one of those wrong does not fail loudly; it + * returns an empty record list that reads as "no arrivals this week". + */ + public static final List REGIONS = List.of( + // CENTRAL — the original pilot. Four districts, because the deck's + // pinned enterprises and the seeded branch network already name them. + new Region("Madhya Pradesh", "मध्य प्रदेश", List.of( + new DistrictSpec("SEHORE", "Sehore", "सीहोर", null, 23.2033, 77.0856), + new DistrictSpec("VIDISHA", "Vidisha", "विदिशा", null, 23.5251, 77.8081), + new DistrictSpec("RAISEN", "Raisen", "रायसेन", null, 23.3300, 77.7800), + new DistrictSpec("BHOPAL", "Bhopal", "भोपाल", null, 23.2599, 77.4126))), + // WEST + new Region("Maharashtra", "महाराष्ट्र", List.of( + // Agmarknet publishes "Nashik", not the older "Nasik" — the + // override that assumed otherwise cost the WEST zone every + // price it had, including the national onion benchmark. + // Verified 2026-08-11: 269 recent onion rows, the largest of + // any district in the state. + new DistrictSpec("NASHIK", "Nashik", "नाशिक", null, 20.0059, 73.7897), + // Renamed Ahilyanagar in 2023 and Agmarknet followed. This is + // the opposite failure to Nashik's — here the feed moved and + // we did not. Verified 2026-08-11: 147 recent onion rows. + new DistrictSpec("AHMEDNAGAR", "Ahmednagar", "अहमदनगर", "Ahilyanagar", 19.0952, 74.7496))), + // NORTH + new Region("Rajasthan", "राजस्थान", List.of( + new DistrictSpec("JAIPUR", "Jaipur", "जयपुर", null, 26.9124, 75.7873), + new DistrictSpec("KOTA", "Kota", "कोटा", null, 25.2138, 75.8648))), + // EAST + new Region("Bihar", "बिहार", List.of( + new DistrictSpec("MUZAFFARPUR", "Muzaffarpur", "मुज़फ़्फ़रपुर", null, 26.1209, 85.3647), + // Replaces Patna, which stopped reporting to Agmarknet in + // March 2025 — nothing on our side to fix. Begusarai carries + // 10k rows current to within days across potato, onion, + // brinjal, tomato and okra. See CommodityBaskets.RETIRED — + // Patna's own row stays, deactivated, with its enterprises. + new DistrictSpec("BEGUSARAI", "Begusarai", "बेगूसराय", null, 25.4182, 86.1272))), + // SOUTH — Kolar is a genuine poultry belt, which makes the sector pack + // land on a district where it is the real local economy. + new Region("Karnataka", "कर्नाटक", List.of( + new DistrictSpec("KOLAR", "Kolar", "कोलार", null, 13.1367, 78.1292), + // "Belgaum" is the subtlest of the three, because it does not + // fail — it answers with rows. They stop in May 2026, while + // "Belagavi" is current to within two days. A retired name + // left serving a frozen archive is worse than one returning + // nothing: the freshness screen sees a district reporting + // successfully and only the arrival dates give it away. + new DistrictSpec("BELAGAVI", "Belagavi", "बेळगावी", null, 15.8497, 74.4977))), + // NORTH_EAST — thinnest Agmarknet coverage of the six. Seeded so the + // zone exists on the national view; if the feed returns nothing the + // freshness screen says so rather than the map quietly showing a gap. + new Region("Assam", "असम", List.of( + new DistrictSpec("KAMRUP", "Kamrup", "कामरूप", null, 26.1445, 91.7362), + new DistrictSpec("NAGAON", "Nagaon", "नगाँव", null, 26.3464, 92.6840)))); + + /** One planned enterprise. {@code pinned} rows carry a score published in the deck. */ + public record Slot(String enterpriseId, String sectorCode, String state, String districtCode, + String block, String band, Integer pinnedScore, + String pinnedName, String pinnedNameHi) { + + public boolean pinned() { + return pinnedScore != null; + } + } + + /** + * The six enterprises printed on the deck's wireframe slide. They keep their + * published ids, names, scores and blocks; the plan places them first and + * fills the rest of Madhya Pradesh's quota around them, so the deck and the + * running demo never disagree. + */ + private static final List PINNED = List.of( + pin("MSME-4417", "POULTRY", "SEHORE", "Sehore", 79, "Sehore Poultry Farm", "सीहोर पोल्ट्री फ़ार्म"), + pin("MSME-3902", "DAIRY", "VIDISHA", "Vidisha", 71, "Sharda Dairy Collective", "शारदा डेयरी समूह"), + pin("MSME-5120", "FOOD_PROC", "RAISEN", "Raisen", 58, "Anand Food Products", "आनंद फ़ूड प्रोडक्ट्स"), + pin("MSME-2861", "RURAL_RETAIL", "SEHORE", "Sehore", 41, "Gramin Kirana Store", "ग्रामीण किराना स्टोर"), + pin("MSME-6033", "HANDICRAFTS", "BHOPAL", "Bhopal", 22, "Meera Handicrafts", "मीरा हस्तशिल्प"), + pin("MSME-4488", "DAIRY", "VIDISHA", "Vidisha", 18, "Krishna Dairy Unit", "कृष्णा डेयरी यूनिट")); + + private static Slot pin(String id, String sector, String districtCode, String block, + int score, String name, String nameHi) { + return new Slot(id, sector, "Madhya Pradesh", districtCode, block, + bandOfPinned(score), score, name, nameHi); + } + + /** + * The pinned scores were published against the thresholds in + * {@code application.properties} (RED ≥ 65, WATCH ≥ 35). Restated here rather + * than injected, because the plan must be a pure function — and because if + * the thresholds are ever retuned, the deck's six do not silently change band + * underneath a slide that has already been presented. + */ + private static String bandOfPinned(int score) { + if (score >= 65) return "RED"; + if (score >= 35) return "WATCH"; + return "STANDARD"; + } + + private PortfolioPlan() { + } + + /** + * Every enterprise the demo should contain, in a stable order. + * + *

Ids are derived from state and index rather than drawn from a random + * sequence, so an enterprise keeps its identity when the plan around it + * changes. Adding a state does not renumber the others. + */ + public static List slots() { + List out = new ArrayList<>(); + + for (int regionIndex = 0; regionIndex < REGIONS.size(); regionIndex++) { + Region region = REGIONS.get(regionIndex); + List pinnedHere = PINNED.stream() + .filter(p -> p.state().equals(region.state())) + .toList(); + out.addAll(pinnedHere); + + // Bands still to place in this state, after whatever the pinned rows + // already contribute. + Deque bands = new ArrayDeque<>(remainingBands(pinnedHere, region.state())); + + // Every slot this state still needs, in sector order, before any band + // is attached. Ids and districts come from the serial, so they are + // unchanged by how the bands are then dealt. + List pending = new ArrayList<>(); + int serial = 0; + int[] quota = perSector(region.state()); + for (int s = 0; s < SECTORS.size(); s++) { + String sector = SECTORS.get(s); + long already = pinnedHere.stream().filter(p -> p.sectorCode().equals(sector)).count(); + int needed = (int) (quota[s] - already); + + for (int i = 0; i < needed; i++) { + DistrictSpec district = region.districts() + .get(serial % region.districts().size()); + pending.add(new Slot(enterpriseId(regionIndex, serial), sector, region.state(), + district.code(), district.name(), null, null, null, null)); + serial++; + } + } + dealBands(pending, bands).forEach(out::add); + } + return out; + } + + /** + * Deals the state's bands across sectors round-robin rather than pouring them + * into the sector list in order. + * + *

Draining a single shuffled deque in sector order looks fair and is not. + * A state holds two REDs and five WATCHes against twenty-one slots, so the + * scarce bands are almost all gone by the time the loop reaches the last pack + * — and because the shuffle is fixed-seed, "almost all" is the same every + * boot rather than varying. Appending HORTICULTURE to {@link #SECTORS} put it + * at the tail and seeded nineteen enterprises, every one of them STANDARD: + * a pack with no RED and no WATCH cannot demonstrate the early-warning + * screens it was added for, and it reads as a genuinely calm sector rather + * than as an artefact of list position. + * + *

Round-robin gives each pack a turn before any pack takes a second, so + * the two REDs land in two different sectors. Order within the state still + * comes from the same fixed-seed shuffle, so the result is stable across + * boots. + */ + private static List dealBands(List pending, Deque bands) { + Map> bySector = new LinkedHashMap<>(); + for (Slot s : pending) { + bySector.computeIfAbsent(s.sectorCode(), k -> new ArrayDeque<>()).add(s); + } + List out = new ArrayList<>(pending.size()); + while (out.size() < pending.size()) { + for (Deque queue : bySector.values()) { + Slot s = queue.poll(); + if (s == null) continue; + String band = bands.isEmpty() ? "STANDARD" : bands.poll(); + out.add(new Slot(s.enterpriseId(), s.sectorCode(), s.state(), s.districtCode(), + s.block(), band, null, null, null)); + } + } + return out; + } + + /** + * The state's band quota minus what the pinned rows already supply, shuffled + * with a fixed seed so the mix is spread across sectors instead of clustering + * every RED in whichever pack the loop reaches first — and identical on every + * boot, because a demo that reshuffles its own risk bands between runs cannot + * be talked through twice. + */ + private static List remainingBands(List pinnedHere, String state) { + int total = Arrays.stream(perSector(state)).sum(); + Map quota = new LinkedHashMap<>(); + quota.put("RED", RED_PER_STATE); + quota.put("WATCH", WATCH_PER_STATE); + quota.put("STANDARD", total - RED_PER_STATE - WATCH_PER_STATE); + + for (Slot p : pinnedHere) { + quota.computeIfPresent(p.band(), (k, v) -> Math.max(0, v - 1)); + } + + List bands = new ArrayList<>(); + quota.forEach((band, count) -> { + for (int i = 0; i < count; i++) bands.add(band); + }); + Collections.shuffle(bands, new Random(20260810L)); + return bands; + } + + /** + * A stable, readable id per state, preserving the deck's four-digit MSME-nnnn + * form. + * + *

The block comes from the region's position in {@link #REGIONS}, not from + * a hash of its name: six states hashed into eight buckets collide with + * roughly ninety-five percent probability, and a collision here would silently + * merge two states' enterprises into one row. Position gives each state its + * own thousand, and appending a state never renumbers an existing one. + */ + private static String enterpriseId(int regionIndex, int serial) { + return "MSME-" + (1000 + regionIndex * 1000 + serial * 7 + 11); + } + + /** Total planned size, for logging and for the reshape guard. */ + public static int size() { + return slots().size(); + } + + private static final Set PINNED_IDS = PINNED.stream() + .map(Slot::enterpriseId).collect(Collectors.toUnmodifiableSet()); + + /** The deck's six, as a set — the seeder asks this once per enterprise. */ + public static Set pinnedIds() { + return PINNED_IDS; + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPurge.java b/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPurge.java new file mode 100644 index 0000000..6da9cd6 --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/seed/PortfolioPurge.java @@ -0,0 +1,69 @@ +package com.lincfusion.udyamlinc.seed; + +import com.lincfusion.udyamlinc.auth.UdyamUserRepository; +import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository; +import com.lincfusion.udyamlinc.enterprise.LoanRecordRepository; +import com.lincfusion.udyamlinc.enterprise.UpiSummaryRepository; +import com.lincfusion.udyamlinc.features.FeatureSnapshotRepository; +import com.lincfusion.udyamlinc.features.FeatureValueRepository; +import com.lincfusion.udyamlinc.officer.FieldVisitRepository; +import com.lincfusion.udyamlinc.risk.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * Deletes everything hanging off one enterprise. + * + *

Exists because the demo portfolio changed shape — from one state to six — + * and an enterprise that leaves the plan cannot simply be dropped from the + * {@code enterprise} table. Ten other tables reference it by {@code enterpriseId} + * as a plain string rather than a foreign key, so the database will not object; + * the rows just become orphans that still answer queries. An alert with no + * enterprise behind it renders on the early-warning screen as a row with a blank + * name, which looks like a bug in the console rather than a bug in the seed. + * + *

Every table is listed explicitly. A loop over entity metadata would be + * shorter and would silently miss the next table someone adds, which is the same + * failure with a longer fuse. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class PortfolioPurge { + + 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 LedgerEntryRepository ledgerRepository; + private final LoanRecordRepository loanRecordRepository; + private final UpiSummaryRepository upiSummaryRepository; + private final FeatureValueRepository featureValueRepository; + private final FeatureSnapshotRepository featureSnapshotRepository; + private final UdyamUserRepository userRepository; + + /** Removes the dependent rows. The {@code enterprise} row itself is the caller's. */ + public void purge(String enterpriseId) { + visitRepository.deleteAll(visitRepository.findByEnterpriseIdOrderByDueDateAsc(enterpriseId)); + alertRepository.deleteAll(alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId)); + reasonRepository.deleteAll(reasonRepository.findByEnterpriseIdOrderByRankAsc(enterpriseId)); + scoreRepository.deleteAll(scoreRepository.findByEnterpriseIdOrderByComputedAtDesc(enterpriseId)); + pointRepository.deleteAll(pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId)); + runRepository.deleteAll(runRepository.findByEnterpriseId(enterpriseId)); + + ledgerRepository.deleteAll(ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId)); + loanRecordRepository.deleteAll(loanRecordRepository.findByEnterpriseIdOrderByAsOfDateAsc(enterpriseId)); + upiSummaryRepository.deleteByEnterpriseId(enterpriseId); + + featureValueRepository.deleteAll(featureValueRepository.findByEnterpriseId(enterpriseId)); + featureSnapshotRepository.deleteAll(featureSnapshotRepository.findByEnterpriseId(enterpriseId)); + + // The enterprise's own PWA login, or the next seed leaves a credential + // that authenticates against a borrower who no longer exists. + userRepository.findByEnterpriseIdAndScopeIgnoreCase(enterpriseId, "ENTERPRISE") + .ifPresent(userRepository::delete); + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/SeedText.java b/src/main/java/com/lincfusion/udyamlinc/seed/SeedText.java index fb2985f..e93a276 100644 --- a/src/main/java/com/lincfusion/udyamlinc/seed/SeedText.java +++ b/src/main/java/com/lincfusion/udyamlinc/seed/SeedText.java @@ -28,19 +28,73 @@ final class SeedText { "भगीरथ", "मालवा", "सहयोग", "एकता", "प्रगति", "सुविधा", "अनमोल", "निर्मल", "वैभव", }; + /** + * Name prefixes drawn from each state's own rivers, ranges and landmarks. + * + *

The original pool is Madhya Pradesh's — Narmada, Betwa, Vindhya, Malwa. + * Reusing it nationally would put a "Betwa Dairy" in Assam, which is the kind + * of detail a NABARD reviewer from that circle notices immediately and which + * makes every other number on the screen look equally unconsidered. Anything + * unlisted falls back to {@link #PREFIX}. + */ + private static final Map PREFIX_BY_STATE = Map.of( + "Maharashtra", new String[]{ + "Godavari", "Sahyadri", "Shivneri", "Krishna", "Jai Bhavani", "Vitthal", + "Sant Tukaram", "Pandharpur", "Shri", "Adarsh", "Pragati", "Ekta"}, + "Rajasthan", new String[]{ + "Aravalli", "Chambal", "Marudhara", "Pushkar", "Mewar", "Marwar", + "Shekhawati", "Banas", "Shri", "Adarsh", "Pragati", "Ekta"}, + "Bihar", new String[]{ + "Ganga", "Kosi", "Gandak", "Mithila", "Magadh", "Vaishali", + "Sone", "Bodhi", "Shri", "Adarsh", "Pragati", "Ekta"}, + "Karnataka", new String[]{ + "Kaveri", "Tungabhadra", "Nandi", "Malnad", "Basava", "Chamundi", + "Hoysala", "Sharavathi", "Shri", "Adarsh", "Pragati", "Ekta"}, + "Assam", new String[]{ + "Brahmaputra", "Kaziranga", "Barak", "Kamakhya", "Luit", "Bihu", + "Sualkuchi", "Majuli", "Shri", "Adarsh", "Pragati", "Ekta"}); + + private static final Map PREFIX_HI_BY_STATE = Map.of( + "Maharashtra", new String[]{ + "गोदावरी", "सह्याद्रि", "शिवनेरी", "कृष्णा", "जय भवानी", "विठ्ठल", + "संत तुकाराम", "पंढरपुर", "श्री", "आदर्श", "प्रगति", "एकता"}, + "Rajasthan", new String[]{ + "अरावली", "चंबल", "मरुधरा", "पुष्कर", "मेवाड़", "मारवाड़", + "शेखावाटी", "बनास", "श्री", "आदर्श", "प्रगति", "एकता"}, + "Bihar", new String[]{ + "गंगा", "कोसी", "गंडक", "मिथिला", "मगध", "वैशाली", + "सोन", "बोधि", "श्री", "आदर्श", "प्रगति", "एकता"}, + "Karnataka", new String[]{ + "कावेरी", "तुंगभद्रा", "नंदी", "मलनाड", "बसव", "चामुंडी", + "होयसल", "शरावती", "श्री", "आदर्श", "प्रगति", "एकता"}, + "Assam", new String[]{ + "ब्रह्मपुत्र", "काज़ीरंगा", "बराक", "कामाख्या", "लुइत", "बिहू", + "शुआलकुची", "माजुली", "श्री", "आदर्श", "प्रगति", "एकता"}); + + /** Prefix pool for a state, and its Hindi counterpart at the same indexes. */ + static String[] prefixesFor(String state) { + return PREFIX_BY_STATE.getOrDefault(state, PREFIX); + } + + static String[] prefixesHiFor(String state) { + return PREFIX_HI_BY_STATE.getOrDefault(state, PREFIX_HI); + } + static final Map> 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")); + "RURAL_RETAIL", List.of("Kirana Store", "General Store", "Provision Store"), + "HORTICULTURE", List.of("Farms", "Bagwani Udyan", "Nursery", "Fruit Farm")); static final Map> SUFFIX_HI = Map.of( "DAIRY", List.of("डेयरी", "दूध केंद्र", "डेयरी यूनिट", "दूध संघ"), "POULTRY", List.of("पोल्ट्री फ़ार्म", "लेयर फ़ार्म", "पोल्ट्री यूनिट"), "FOOD_PROC", List.of("फ़ूड प्रोडक्ट्स", "आटा चक्की", "मसाला उद्योग", "एग्रो फ़ूड्स"), "HANDICRAFTS", List.of("हस्तशिल्प", "हथकरघा", "क्राफ़्ट वर्क्स"), - "RURAL_RETAIL", List.of("किराना स्टोर", "जनरल स्टोर", "प्रोविज़न स्टोर")); + "RURAL_RETAIL", List.of("किराना स्टोर", "जनरल स्टोर", "प्रोविज़न स्टोर"), + "HORTICULTURE", List.of("फ़ार्म्स", "बागवानी उद्यान", "नर्सरी", "फ़्रूट फ़ार्म")); /** driver → { en, hi } sentence, per sector. */ static final Map> REASONS = Map.of( @@ -73,7 +127,16 @@ final class SeedText { "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.", "ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।"})); + "externalShock", new String[]{"Local market disruption flagged in the block.", "ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।"}), + // A grower's stress reads differently from a processor's: no stock to + // hold, no buyer to chase, and a price that can halve between one + // market day and the next. + "HORTICULTURE", Map.of( + "liquidity", new String[]{"Harvest sold below cost as arrivals peaked together.", "एक साथ आवक बढ़ने से फ़सल लागत से कम भाव पर बिकी।"}, + "behavioural", new String[]{"Selling through one commission agent on deferred payment.", "एक ही आढ़तिये को उधार पर माल दिया जा रहा है।"}, + "trendVolatility", new String[]{"Mandi rate swung more than 40% inside a fortnight.", "पखवाड़े भर में मंडी भाव 40% से ज़्यादा घट-बढ़ा है।"}, + "obligationLoad", new String[]{"Instalment falls between harvests, with no crop to sell.", "किस्त दो फ़सलों के बीच पड़ रही है, बेचने को कुछ नहीं।"}, + "externalShock", new String[]{"Unseasonal rain in the block during the picking window.", "तुड़ाई के समय ब्लॉक में बेमौसम बारिश हुई है।"})); /** Driver key → Hindi label, in the order the console renders them. */ static final String[][] DRIVERS = { diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java b/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java index 96aa9e5..5deb0d3 100644 --- a/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java +++ b/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java @@ -48,7 +48,6 @@ public class UdyamDataSeeder implements CommandLineRunner { public static final String DEFAULT_OFFICER_PASSWORD = "Udyam@123"; public static final String DEFAULT_ENTERPRISE_PIN = "1234"; - private static final int TOTAL = 142; /** * Days of ledger history per demo enterprise. Five months rather than one, @@ -58,7 +57,6 @@ public class UdyamDataSeeder implements CommandLineRunner { * NO_FORECAST. */ private static final int LEDGER_DAYS = 150; - private static final Map TARGET = Map.of("RED", 9, "WATCH", 31, "STANDARD", 102); private static final String MODEL_VERSION = "rule-v0.9.3"; private final DistrictRepository districtRepository; @@ -66,6 +64,7 @@ public class UdyamDataSeeder implements CommandLineRunner { private final MarketRepository marketRepository; private final SectorRepository sectorRepository; private final SectorCommodityRepository sectorCommodityRepository; + private final DistrictCommodityRepository districtCommodityRepository; private final RoleRepository roleRepository; private final EnterpriseRepository enterpriseRepository; private final LedgerEntryRepository ledgerRepository; @@ -81,11 +80,20 @@ public class UdyamDataSeeder implements CommandLineRunner { private final UdyamUserRepository userRepository; private final PasswordEncoder passwordEncoder; private final InstitutionSeeder institutionSeeder; + private final RegionalOfficeSeeder regionalOfficeSeeder; private final com.lincfusion.udyamlinc.connector.ConnectorProfileSeeder connectorProfileSeeder; + private final PortfolioPurge portfolioPurge; @Value("${udyam.seed.enabled:true}") private boolean seedEnabled; + /** + * Destructive, so off unless asked for. See + * {@code reshapeIfRequested} — one boot with it on, then off again. + */ + @Value("${udyam.seed.reshape:false}") + private boolean reshapeEnabled; + @Value("${udyam.risk.red-threshold:65}") private int redThreshold; @@ -106,12 +114,16 @@ public class UdyamDataSeeder implements CommandLineRunner { seedDistricts(); seedCommodities(); seedMarkets(); + // After both districts and commodities exist — it joins the two. + seedDistrictCommodities(); seedSectors(); seedSectorCommodities(); seedTripWireRules(); featureCatalog.seed(); seedOfficers(); seedPortfolio(); + // Regional offices before the institutions that roll up into them. + regionalOfficeSeeder.seed(); // The institutional tier last: it assigns the portfolio the steps above // created, so it has to see a finished book. institutionSeeder.seed(); @@ -124,7 +136,25 @@ public class UdyamDataSeeder implements CommandLineRunner { // ── masters ───────────────────────────────────────────── private void seedRoles() { - if (roleRepository.count() > 0) return; + // Upserted per code rather than guarded on the table, so RO_ADMIN and + // RO_OFFICER reach a database seeded before the office tier existed. + List planned = List.of( + Role.builder().code("RO_ADMIN").name("Regional Office Administrator").systemRole(true) + // No BORROWER permission of any kind, deliberately. An RO + // supervises banks; the entitlement it must never acquire + // is the one that would let it open a customer. + .permissions("PORTFOLIO:VIEW,ALERT:VIEW,REPORT:VIEW,CONFIG:MANAGE").build(), + Role.builder().code("RO_OFFICER").name("Regional Office Officer").systemRole(true) + .permissions("PORTFOLIO:VIEW,ALERT:VIEW,REPORT:VIEW").build()); + int added = 0; + for (Role r : planned) { + if (roleRepository.findByCode(r.getCode()).isPresent()) continue; + roleRepository.save(r); + added++; + } + if (added > 0) log.info("Seeded {} regional-office roles", added); + + if (roleRepository.count() > added) 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(), @@ -135,72 +165,225 @@ public class UdyamDataSeeder implements CommandLineRunner { log.info("Seeded {} roles", roleRepository.count()); } - /** The pilot's four districts, with centroids for picking the climate grid cell. */ + /** + * The pilot's districts, with centroids for picking the climate grid cell. + * + *

Upserted one code at a time rather than guarded by {@code count() > 0}. + * The old guard meant a district added after a database was first populated + * never arrived — the same trap that left loan records missing for a release, + * except here it would be silent in a worse way: the state would be absent + * from the national map with no error anywhere, just a smaller portfolio. + */ 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) { - String state = "Madhya Pradesh"; - return District.builder().code(code).name(name).nameHi(nameHi) - .state(state).zone(Zones.of(state)).latitude(lat).longitude(lon).active(true).build(); + int written = 0, updated = 0; + for (PortfolioPlan.Region region : PortfolioPlan.REGIONS) { + for (PortfolioPlan.DistrictSpec spec : region.districts()) { + Optional existing = districtRepository.findByCode(spec.code()); + if (existing.isPresent()) { + // Feed spellings arrived after the first four districts were + // seeded, so an existing row needs them backfilled or its + // Agmarknet calls keep matching nothing. + District d = existing.get(); + if (!Objects.equals(d.getSourceName(), spec.sourceName())) { + d.setSourceName(spec.sourceName()); + districtRepository.save(d); + updated++; + } + continue; + } + districtRepository.save(District.builder() + .code(spec.code()).name(spec.name()).nameHi(spec.nameHi()) + .sourceName(spec.sourceName()) + .state(region.state()).zone(Zones.of(region.state())) + .latitude(spec.latitude()).longitude(spec.longitude()) + .active(true).build()); + written++; + } + } + if (written > 0) log.info("Seeded {} districts", written); + if (updated > 0) log.info("Backfilled feed spellings on {} existing districts", updated); } /** - * Commodities the sector packs track. {@code sourceCode} is the name the - * upstream feed uses, which rarely matches ours. + * Commodities the sector packs track, reconciled against + * {@link CommodityBaskets#COMMODITIES} one code at a time. + * + *

Not guarded by {@code count() > 0}. That guard is why the fruit and + * vegetable rows would otherwise never have arrived: the table has been + * non-empty since the first boot, so the whole method returned before + * reaching them, and nothing anywhere reported a commodity as missing. + * + *

{@code sourceCode} on an existing row is deliberately left alone. + * Rewriting one would orphan every {@code market_signal} already ingested + * under the old spelling — the history would still be in the table and no + * longer joinable to the commodity that produced it. Divergence is logged + * instead, loudly enough to be dealt with by hand. */ 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"), - // Agmarknet carries Green Fodder, but every recent row is one market - // in Ambala, Haryana — nothing for the pilot districts. Left as a - // manual-entry commodity rather than a nightly call that can only - // ever return an empty page. - commodity("FODDER", "Green fodder", "हरा चारा", "MANUAL", null, "QUINTAL"))); - log.info("Seeded {} commodities", commodityRepository.count()); + int written = 0, updated = 0; + for (CommodityBaskets.CommoditySpec spec : CommodityBaskets.COMMODITIES) { + Optional existing = commodityRepository.findByCode(spec.code()); + if (existing.isPresent()) { + Commodity c = existing.get(); + if (!Objects.equals(c.getSourceCode(), spec.sourceCode())) { + log.warn("Commodity {} has sourceCode '{}' on book but the plan says '{}'. Left as is — " + + "changing it would orphan ingested market_signal rows. Fix by hand if the plan is right.", + spec.code(), c.getSourceCode(), spec.sourceCode()); + } + // Classification and the verified flag are safe to reconcile: + // neither is a join key, and both were absent before this change. + if (c.getCommodityGroup() == null || !Objects.equals(c.getCommodityGroup(), spec.group())) { + c.setCommodityGroup(spec.group()); + if (c.getSourceVerified() == null) c.setSourceVerified(spec.verified()); + commodityRepository.save(c); + updated++; + } + continue; + } + commodityRepository.save(Commodity.builder() + .code(spec.code()).name(spec.name()).nameHi(spec.nameHi()) + .source(spec.source()).sourceCode(spec.sourceCode()) + .commodityGroup(spec.group()).unit(spec.unit()) + .sourceVerified(spec.verified()).active(true).build()); + written++; + } + if (written > 0) log.info("Seeded {} commodities", written); + if (updated > 0) log.info("Classified {} existing commodities", updated); } - 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(); + /** + * What each district trades. Replaces the district × commodity cross product + * ingestion used to walk — see {@link DistrictCommodity} for why that stops + * working the moment perishables are tracked across six states. + */ + private void seedDistrictCommodities() { + int written = 0, missing = 0, retired = 0, restored = 0, deactivated = 0; + for (District district : districtRepository.findAll()) { + String retiredReason = CommodityBaskets.RETIRED.get(district.getCode()); + if (retiredReason != null) { + if (Boolean.TRUE.equals(district.getActive())) { + district.setActive(false); + districtRepository.save(district); + deactivated++; + log.warn("Deactivated district {} — {}", district.getCode(), retiredReason); + } + // Retire its pairs too, or they sit active against a district + // ingestion no longer visits and read as healthy on the console. + for (DistrictCommodity dc : districtCommodityRepository + .findByDistrictIdAndActiveTrue(district.getId())) { + dc.setActive(false); + districtCommodityRepository.save(dc); + retired++; + } + continue; + } + List basket = CommodityBaskets.BASKETS.get(district.getCode()); + if (basket == null) { + log.warn("District {} has no basket in CommodityBaskets — ingestion will pull nothing for it", + district.getCode()); + missing++; + continue; + } + Set planned = new HashSet<>(); + for (String entry : basket) { + String code = CommodityBaskets.commodityCode(entry); + Long commodityId = commodityRepository.findByCode(code).map(Commodity::getId).orElse(null); + if (commodityId == null) { + log.warn("Basket for {} names unknown commodity {}", district.getCode(), code); + continue; + } + planned.add(commodityId); + Optional existing = districtCommodityRepository + .findByDistrictIdAndCommodityId(district.getId(), commodityId); + if (existing.isPresent()) { + // Back in the plan after being retired — a probe that + // vindicates a spelling should not need a manual re-insert. + if (!Boolean.TRUE.equals(existing.get().getActive())) { + existing.get().setActive(true); + districtCommodityRepository.save(existing.get()); + restored++; + } + continue; + } + districtCommodityRepository.save(DistrictCommodity.builder() + .districtId(district.getId()).commodityId(commodityId) + .benchmark(CommodityBaskets.isBenchmark(entry)) + .active(true).build()); + written++; + } + + // Deactivated, not deleted. A pair dropped because the feed turned out + // not to carry it — Muzaffarpur litchi, Nagaon banana — is a finding + // worth keeping: the row records that we looked and what we found, and + // deleting it invites the next person to add the same basket entry + // again for the same wrong reason. + for (DistrictCommodity dc : districtCommodityRepository + .findByDistrictIdAndActiveTrue(district.getId())) { + if (planned.contains(dc.getCommodityId())) continue; + dc.setActive(false); + districtCommodityRepository.save(dc); + retired++; + } + } + if (written > 0) log.info("Seeded {} district-commodity pairs ({} planned)", + written, CommodityBaskets.pairCount()); + if (restored > 0) log.info("Reactivated {} district-commodity pairs", restored); + if (retired > 0) log.info("Retired {} district-commodity pairs no longer in the basket plan", retired); + if (deactivated > 0) log.warn("Deactivated {} districts that no longer report", deactivated); + if (missing > 0) log.warn("{} districts have no basket", missing); } + /** One primary mandi per district, guarded per code so a new district gets one. */ 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()); + int written = 0; + for (District d : districtRepository.findAll()) { + String code = d.getCode() + "-MANDI"; + if (marketRepository.findByCode(code).isPresent()) continue; + marketRepository.save(Market.builder() + .code(code) + .name(d.getName() + " Krishi Upaj Mandi") + .sourceName(d.feedDistrict()) + .districtId(d.getId()) + .primaryMarket(true) + .active(true) + .build()); + written++; + } + if (written > 0) log.info("Seeded {} markets", written); } - /** The five risk packs. Weights are basis points and sum to 10000. */ + /** + * The risk packs. Weights are basis points and sum to 10000. + * + *

Upserted per code, not guarded by {@code count() > 0} — the guard is + * why HORTICULTURE would otherwise never reach a database seeded before it + * existed. Weights on an existing pack are left alone: they are a + * supervisor's calibration, and a deploy silently restoring them to the + * shipped defaults is exactly the kind of change nobody would notice. + */ private void seedSectors() { - if (sectorRepository.count() > 0) return; - sectorRepository.saveAll(List.of( + List planned = 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()); + sector("RURAL_RETAIL", "Rural retail", "ग्रामीण दुकान", "Rural retail", 3000, 2400, 1800, 1800, 1000), + // Horticulture carries the heaviest external-shock weight of the + // six and the lightest obligation load. A grower's stress arrives + // as a price collapse or a hailstorm inside one week — Nashik + // onion and Kolar tomato both routinely halve — where a dairy's + // arrives as a margin that thins over a season. The pack has to + // read the shock family harder or it will be late on exactly the + // enterprises it exists to warn about. + sector("HORTICULTURE", "Horticulture", "बागवानी", "Horticulture", 2600, 1600, 2000, 1200, 2600)); + + int written = 0; + for (Sector s : planned) { + if (sectorRepository.findByCode(s.getCode()).isPresent()) continue; + sectorRepository.save(s); + written++; + } + if (written > 0) log.info("Seeded {} sector packs", written); } private Sector sector(String code, String label, String labelHi, String shortLabel, @@ -215,7 +398,6 @@ public class UdyamDataSeeder implements CommandLineRunner { * 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); @@ -225,13 +407,64 @@ public class UdyamDataSeeder implements CommandLineRunner { 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()); + + // Horticulture sells what it grows and buys almost nothing this feed can + // see — seed, labour and transport are its real costs and none has a + // mandi price. So the pack is OUTPUT-heavy on purpose, and margin squeeze + // for a grower is a revenue collapse rather than a cost spike. Weights + // are even across the five because which one a given grower actually + // sells is not in the schema; a real deployment would set this per + // enterprise from the loan application's crop declaration. + link("HORTICULTURE", "ONION", "OUTPUT", 2000); + link("HORTICULTURE", "TOMATO", "OUTPUT", 2000); + link("HORTICULTURE", "POTATO", "OUTPUT", 2000); + link("HORTICULTURE", "BANANA", "OUTPUT", 2000); + link("HORTICULTURE", "MANGO", "OUTPUT", 2000); + // The one cost that does have a feed: a grower diversifying into a cereal + // buys seed at the same mandi that prices the crop. + link("HORTICULTURE", "MAIZE", "INPUT", 10000); + + // Food processing buys perishables too — a tomato-paste or pickle unit is + // exactly the enterprise a vegetable price spike hurts. Re-weighted from + // 5000/5000 so the four inputs still sum to 10000. + link("FOOD_PROC", "TOMATO", "INPUT", 5000); + link("FOOD_PROC", "POTATO", "INPUT", 5000); + + // Handicrafts had no commodity of any kind, so four features were null + // for forty-seven enterprises. Cotton is a real input to a handloom unit + // and we already carry it verified — 958,962 rows nationally, current to + // yesterday. It is an INPUT only: there is no mandi rate for a finished + // sari, and inventing an output price would manufacture a margin for an + // enterprise whose margin has nothing to do with a mandi. + // + // This yields a value only where cotton actually trades, which today is + // Ahmednagar and Belagavi. The rest stay NO_SIGNAL, correctly — the link + // makes the feature computable where the data exists rather than + // everywhere at once. + link("HANDICRAFTS", "COTTON", "INPUT", 6000); + + // Simulated output indices for the three packs that have no traded + // output. See CommodityBaskets — these are labelled synthetic in four + // separate places, because unlike milk and egg they do not stand in for + // a real price that merely lacks an API. They exist so margin_squeeze, + // output_price_change and price_z_score compute for every pack. + link("FOOD_PROC", "PROCESSED_FOOD_INDEX", "OUTPUT", 10000); + link("RURAL_RETAIL", "RETAIL_BASKET_INDEX", "OUTPUT", 10000); + link("HANDICRAFTS", "HANDLOOM_INDEX", "OUTPUT", 10000); + // Yarn carries handicrafts everywhere cotton does not trade, which is + // twelve of the fourteen districts. + link("HANDICRAFTS", "YARN_INDEX", "INPUT", 4000); } + /** + * Upserted per (sector, commodity) pair rather than guarded by a table-level + * {@code count() > 0}, so a link added to an existing database arrives. + */ 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; + if (sectorCommodityRepository.findBySectorIdAndCommodityId(sectorId, commodityId).isPresent()) return; sectorCommodityRepository.save(SectorCommodity.builder() .sectorId(sectorId).commodityId(commodityId).role(role).weight(weight).build()); } @@ -240,11 +473,24 @@ public class UdyamDataSeeder implements CommandLineRunner { * The five deterministic trip-wires. Expressions are SpEL over the feature * snapshot; the evaluator that runs them arrives with the feature store. */ + /** + * The trip-wires, upserted by code rather than guarded by {@code count() > 0}. + * + *

The old guard meant a rule added or corrected after a database was first + * populated never arrived — and unlike a missing enterprise, a missing rule is + * silent: the console shows the rules it has and nothing indicates one is + * absent. + * + *

TW-01 and TW-06 split what used to be one rule. Firing a single + * red-severity rule on the P10 breach meant every enterprise whose *downside* + * fell short was called red, which is not what a P10 says and which emptied + * the watch band — a hundred and ten enterprises produced three watches. The + * central case missing is red; the downside case missing is a watch. + */ private void seedTripWireRules() { - if (ruleRepository.count() > 0) return; - ruleRepository.saveAll(List.of( + upsertRules(List.of( rule("TW-01", "Projected DSCR below 1.0", "किस्त चुकाने की क्षमता 1.0 से नीचे", - "dscr_p10 < 1.0", "DSCR(P10) < 1.00", "RED", "dscr_p10", 7), + "dscr_p50 < 1.0", "DSCR(P50) < 1.00", "RED", "dscr_p50", 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 दिन से हिसाब दर्ज नहीं", @@ -253,8 +499,51 @@ public class UdyamDataSeeder implements CommandLineRunner { "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()); + "price_z_score,heat_anomaly", 14), + rule("TW-06", "Downside case misses the instalment", "बुरी स्थिति में किस्त छूट सकती है", + "dscr_p10 < 1.0", "DSCR(P10) < 1.00", "WATCH", "dscr_p10", 7))); + } + + /** + * Insert what is missing and correct what has changed, leaving any rule an + * operator has edited in the console alone. + * + *

{@code version} is the discriminator: the seeder only overwrites a rule + * still on version 1, which is the version it wrote. A supervisor who retunes + * a threshold bumps it, and their change survives every subsequent boot — + * otherwise a restart would silently revert a risk decision somebody made + * deliberately. + */ + private void upsertRules(List wanted) { + int written = 0, corrected = 0; + for (TripWireRule want : wanted) { + Optional existing = ruleRepository.findByCode(want.getCode()); + if (existing.isEmpty()) { + ruleRepository.save(want); + written++; + continue; + } + TripWireRule have = existing.get(); + if (have.getVersion() != null && have.getVersion() > 1) continue; + if (Objects.equals(have.getExpression(), want.getExpression()) + && Objects.equals(have.getSeverity(), want.getSeverity()) + && Objects.equals(have.getRequiredFeatures(), want.getRequiredFeatures())) { + continue; + } + have.setLabel(want.getLabel()); + have.setLabelHi(want.getLabelHi()); + have.setExpression(want.getExpression()); + have.setThresholdText(want.getThresholdText()); + have.setSeverity(want.getSeverity()); + have.setRequiredFeatures(want.getRequiredFeatures()); + have.setMaxStalenessDays(want.getMaxStalenessDays()); + have.setUpdatedBy("seed"); + have.setUpdatedAt(LocalDateTime.now()); + ruleRepository.save(have); + corrected++; + } + if (written > 0) log.info("Seeded {} trip-wire rules", written); + if (corrected > 0) log.info("Corrected {} seed-owned trip-wire rules", corrected); } private TripWireRule rule(String code, String label, String labelHi, String expression, @@ -284,18 +573,6 @@ public class UdyamDataSeeder implements CommandLineRunner { // ── 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 = 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 PINNED_SUBSCORES = Map.of("MSME-4417", new int[]{26, 19, 14, 11, 9}); @@ -304,72 +581,108 @@ public class UdyamDataSeeder implements CommandLineRunner { private static final int PINNED_BREACH_HORIZON = 3; private static final BigDecimal PINNED_SHORTFALL = new BigDecimal("12400"); + /** + * Converges the book on {@link PortfolioPlan}: seeds every planned enterprise + * that is missing, then backfills the per-enterprise rows onto any that were + * already there. + * + *

Written as "converge on a target" rather than "populate if empty" because + * the shape has changed once already — from 142 enterprises in one state to + * eighteen in each of six — and a first-boot-only seeder cannot express that + * on a database someone has already run. + */ private void seedPortfolio() { - if (enterpriseRepository.count() > 0) { - backfillPortfolio(); - return; - } + List plan = PortfolioPlan.slots(); + reshapeIfRequested(plan); Map sectors = new HashMap<>(); sectorRepository.findAll().forEach(s -> sectors.put(s.getCode(), s)); Map districts = new HashMap<>(); - districtRepository.findAll().forEach(d -> districts.put(d.getName(), d)); + districtRepository.findAll().forEach(d -> districts.put(d.getCode(), d)); - List bandPlan = buildBandPlan(); - List sectorCodes = List.of("DAIRY", "POULTRY", "FOOD_PROC", "HANDICRAFTS", "RURAL_RETAIL"); - List 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)); + int created = 0; + for (PortfolioPlan.Slot slot : plan) { + if (enterpriseRepository.findByEnterpriseId(slot.enterpriseId()).isPresent()) continue; + District d = districts.get(slot.districtCode()); + Sector sector = sectors.get(slot.sectorCode()); + if (d == null || sector == null) { + log.warn("Skipping {} — district {} or sector {} is not seeded", + slot.enterpriseId(), slot.districtCode(), slot.sectorCode()); + continue; + } + save(build(slot, sector, d)); + created++; } + if (created > 0) log.info("Seeded {} enterprises across {} states", created, PortfolioPlan.REGIONS.size()); - 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 suffix = SeedText.SUFFIX.get(sectorCode); - List 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)); - } + backfillPortfolio(); } /** - * 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. + * Removes what the plan no longer contains — but only when explicitly asked. + * + *

Off by default. Narrowing the book from 142 to 108 means deleting + * enterprises and every scoring run, alert, visit and ledger entry hanging off + * them, and a seeder that does that on an ordinary restart is a seeder that + * eventually eats something a demo needed an hour before it was given. Set + * {@code udyam.seed.reshape=true} (or {@code UDYAM_SEED_RESHAPE=true}) for the + * one boot that re-shapes, then turn it off. */ - private List buildBandPlan() { - Map pinnedCounts = new HashMap<>(); - for (Pinned p : PINNED) { - pinnedCounts.merge(bandOf(p.score()), 1, Integer::sum); + private void reshapeIfRequested(List plan) { + Set planned = plan.stream() + .map(PortfolioPlan.Slot::enterpriseId).collect(java.util.stream.Collectors.toSet()); + List strays = enterpriseRepository.findAll().stream() + .filter(e -> !planned.contains(e.getEnterpriseId())) + .toList(); + if (strays.isEmpty()) return; + + if (!reshapeEnabled) { + log.warn("{} enterprises on book are not in the current plan ({} planned). " + + "Leaving them alone — set udyam.seed.reshape=true for one boot to remove them.", + strays.size(), planned.size()); + return; } - List 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; + + log.warn("Reshape enabled — removing {} enterprises no longer in the plan, with their dependent rows", + strays.size()); + for (Enterprise e : strays) { + portfolioPurge.purge(e.getEnterpriseId()); + } + enterpriseRepository.deleteAll(strays); + log.warn("Reshape complete — {} enterprises removed", strays.size()); } - private Enterprise build(String enterpriseId, String name, String nameHi, String sectorCode, String block, - int score, Map sectors, Map districts) { - Random r = seeded(enterpriseId); - District d = districts.get(block); + private Enterprise build(PortfolioPlan.Slot slot, Sector sector, District d) { + Random r = seeded(slot.enterpriseId()); + String name; + String nameHi; + int score; + + if (slot.pinned()) { + name = slot.pinnedName(); + nameHi = slot.pinnedNameHi(); + score = slot.pinnedScore(); + } else { + // Names drawn from the state's own pool, so a Bihar enterprise is not + // called after a Madhya Pradesh river. + String[] prefixes = SeedText.prefixesFor(slot.state()); + String[] prefixesHi = SeedText.prefixesHiFor(slot.state()); + int n = r.nextInt(Math.min(prefixes.length, prefixesHi.length)); + List suffix = SeedText.SUFFIX.get(slot.sectorCode()); + List suffixHi = SeedText.SUFFIX_HI.get(slot.sectorCode()); + int s = r.nextInt(suffix.size()); + name = prefixes[n] + " " + suffix.get(s); + nameHi = prefixesHi[n] + " " + suffixHi.get(s); + score = scoreForBand(r, slot.band()); + } + return Enterprise.builder() - .enterpriseId(enterpriseId) + .enterpriseId(slot.enterpriseId()) .name(name) .nameHi(nameHi) - .sectorId(sectors.get(sectorCode).getId()) + .sectorId(sector.getId()) .districtId(d.getId()) - .block(block) + .block(slot.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) @@ -643,15 +956,28 @@ public class UdyamDataSeeder implements CommandLineRunner { } /** - * 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. + * Five months of ledger history for every enterprise, plus a PWA login for the + * ones the demo signs into. + * + *

This used to cover only the six pinned enterprises, on the reasoning that + * generating entries for the whole book would slow first boot for no demo + * value. That reasoning was wrong in a way that only showed up downstream: the + * deterministic forecast takes its baseline from the ledger, so an enterprise + * without one produces no {@code dscr_p10}, four liquidity features sat at + * about four percent coverage, and the console had to be told to trust seeded + * forecast rows to look populated at all. Ledger coverage is what lets that + * flag go off. + * + *

At 108 enterprises this is roughly 30,000 rows on a first boot — seconds, + * once, against a demo database. */ private void seedLedger(Enterprise e) { - boolean pinned = PINNED.stream().anyMatch(p -> p.id().equals(e.getEnterpriseId())); - if (!pinned) return; + if (ledgerRepository.countByEnterpriseId(e.getEnterpriseId()) > 0) return; - if (userRepository.findByEnterpriseIdAndScopeIgnoreCase(e.getEnterpriseId(), "ENTERPRISE").isEmpty()) { + // A login only for the pinned six: those are the ones the demo actually + // signs into, and 108 dormant credentials is a worse default than six. + boolean pinned = PortfolioPlan.pinnedIds().contains(e.getEnterpriseId()); + if (pinned && userRepository.findByEnterpriseIdAndScopeIgnoreCase(e.getEnterpriseId(), "ENTERPRISE").isEmpty()) { userRepository.save(UdyamUser.builder() .username(e.getEnterpriseId().toLowerCase()) .fullName(e.getName()) @@ -665,17 +991,122 @@ public class UdyamDataSeeder implements CommandLineRunner { String[] outCats = {"INPUT", "WAGES", "TRANSPORT", "EMI"}; List entries = new ArrayList<>(); - for (int d = LEDGER_DAYS; d >= 0; d--) { - int count = 1 + r.nextInt(3); - for (int i = 0; i < count; i++) { - boolean in = r.nextDouble() > 0.45; + // The ledger is anchored to the enterprise's own obligation and band + // rather than drawn from a flat range. Amounts unrelated to the instalment + // would give a RED enterprise a comfortable computed DSCR, so the band on + // the dashboard and the forecast underneath it would contradict each other + // — and with seeded forecasts no longer trusted, the forecast is what the + // console shows. Coverage alone is not enough; the series has to mean + // something. + double obligation = Math.max(2_000, e.getMonthlyInstalment().doubleValue()); + + // STANDARD sits well clear of the instalment rather than just above it. + // The headroom is not cosmetic. With no output-price feed for egg or milk + // — there is no centralised source for either — the forecast runs on input + // costs alone, which puts the spread at 0.28 and a month-three P10 at + // about 0.52 of P50. A healthy enterprise at 1.95× coverage would land on + // 1.01 and trip TW-01 on rounding. The band has to clear roughly 1.94 for + // the rule to mean anything, so the healthy band starts well above it — + // which is also what a genuinely sound microenterprise looks like. + // Each band has to sit clear of the line the rules actually test, not just + // on the intuitive side of it. + // + // Two multipliers stand between coverage and a trip-wire: margin drift, + // which scales P50 by as little as 0.70 when input costs are running at + // the drift cap, and the P10 spread, about 0.515 at month three with no + // output-price feed. WATCH at 1.18 looked comfortably above 1.0 and was + // not — after drift its P50 landed on 1.01, so a third of the watch band + // tripped the red rule and the band all but disappeared. These values are + // chosen so RED fails P50 and WATCH fails only P10, with margin either + // side rather than a rounding error. + double coverage = switch (e.getBand()) { + case "RED" -> 0.78; // the central case cannot cover the instalment + case "WATCH" -> 1.55; // covers it centrally; the downside does not + default -> 2.75; + }; + // Deteriorating for the stressed bands, mildly improving otherwise, so the + // trailing window a forecast reads has a direction and not just a level. + double monthlyDrift = switch (e.getBand()) { + case "RED" -> -0.055; + case "WATCH" -> -0.02; + default -> 0.012; + }; + // Outflow scales with the band too, so total inflow does — which is what + // TW-04 reads as emi_to_inflow. A single flat ratio put every enterprise + // near 0.2 to 0.3 and TW-04 could never fire, leaving TW-01 as the only + // rule that ever produced an alert. A stressed enterprise turning over + // little more than twice its instalment is both realistic and the case + // TW-04 exists to catch. + double outflowRatio = switch (e.getBand()) { + case "RED" -> 1.35 + r.nextDouble() * 0.25; + case "WATCH" -> 1.90 + r.nextDouble() * 0.35; + default -> 2.10 + r.nextDouble() * 0.60; + }; + + // Roughly one enterprise in twenty has gone quiet. Deterministic, so the + // same ones are silent on every boot and the demo can be talked through + // twice. Kept under 27 days so at most one synthetic month is short and + // the median baseline the forecast reads still has whole months either + // side of it. The pinned six are never silent — they are the ones the + // demo signs into. + int silentDays = 0; + if (!pinned && Math.floorMod(e.getEnterpriseId().hashCode(), 20) == 0) { + silentDays = 16 + seeded(e.getEnterpriseId() + ":silent").nextInt(11); + } + + // Month by month, not day by day, and each month's total is distributed + // across its days by weights that sum to one. + // + // Jittering each day independently looks equivalent and is not: at ±35% a + // day, a month of thirty days carries a standard deviation of roughly a + // third of the instalment, while the drift being modelled is one to five + // percent a month. The drift is then invisible under the noise — a + // STANDARD enterprise's cash position trends *downwards* about as often as + // up. Normalised weights keep the daily variation and make the monthly + // total exact, which is the number every downstream feature reads. + int monthCount = LEDGER_DAYS / 30; + for (int monthIndex = 1; monthIndex <= monthCount; monthIndex++) { + // monthIndex 1 is the oldest whole month, so drift runs forward. + int newestDay = LEDGER_DAYS - monthIndex * 30; + int oldestDay = newestDay + 29; + + // Drift accumulates towards the present: the oldest whole month is the + // undrifted baseline, the newest carries the full deterioration. + double netThisMonth = obligation * coverage * (1 + monthlyDrift * (monthIndex - 1)); + double outThisMonth = obligation * outflowRatio; + double inThisMonth = netThisMonth + outThisMonth; + + double[] inWeights = weights(r, 30); + double[] outWeights = weights(r, 30); + + for (int i = 0; i < 30; i++) { + int d = oldestDay - i; + if (d < 0) continue; + // The silent cohort simply stops recording. Without it every + // enterprise reports right up to today, days_since_last_entry is + // zero across the book and TW-03 can never fire — leaving the + // rules screen showing one rule doing all the work, which is a + // poor advertisement for a rule engine. + if (d < silentDays) continue; + LocalDate date = LocalDate.now().minusDays(d); + entries.add(LedgerEntry.builder() - .clientId("seed-" + e.getEnterpriseId() + "-" + d + "-" + i) + .clientId("seed-" + e.getEnterpriseId() + "-" + d + "-in") .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)) + .kind("IN") + .amount(money(Math.max(100, inThisMonth * inWeights[i]))) + .category(inCats[r.nextInt(inCats.length)]) + .entryDate(date) + .syncedAt(LocalDateTime.now()) + .build()); + + entries.add(LedgerEntry.builder() + .clientId("seed-" + e.getEnterpriseId() + "-" + d + "-out") + .enterpriseId(e.getEnterpriseId()) + .kind("OUT") + .amount(money(Math.max(100, outThisMonth * outWeights[i]))) + .category(outCats[r.nextInt(outCats.length)]) + .entryDate(date) .syncedAt(LocalDateTime.now()) .build()); } @@ -714,6 +1145,21 @@ public class UdyamDataSeeder implements CommandLineRunner { return new Random(key.hashCode()); } + /** + * {@code n} positive weights summing to exactly one — the day-to-day variation + * within a month, without moving the month's total. + */ + private double[] weights(Random r, int n) { + double[] w = new double[n]; + double sum = 0; + for (int i = 0; i < n; i++) { + w[i] = 0.4 + r.nextDouble(); + sum += w[i]; + } + for (int i = 0; i < n; i++) w[i] /= sum; + return w; + } + private BigDecimal money(double v) { return BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0d59724..e244f50 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -26,7 +26,11 @@ spring.datasource.hikari.auto-commit=true # throw NoHandlerFoundException for 404s spring.mvc.throw-exception-if-no-handler-found=true -jwt.secret=55647961_6D4C_696E_6320_4E41_4241_5244_5F32_3032_365F_4C69_6E63_4675_7369_6F6E_5F48_6163_6B61_74686F6E +# Demo signing key, overridable by environment. The default is committed on +# purpose so a fresh clone boots without setup — which also means it is public, +# and therefore not a secret. Any deployment carrying real borrower data must set +# UDYAM_JWT_SECRET; a key readable from the repository signs anyone's token. +jwt.secret=${UDYAM_JWT_SECRET:55647961_6D4C_696E_6320_4E41_4241_5244_5F32_3032_365F_4C69_6E63_4675_7369_6F6E_5F48_6163_6B61_74686F6E} # 24 hours jwt.expiration=86400000 @@ -40,6 +44,26 @@ udyam.forecast.horizon-months=6 udyam.risk.red-threshold=65 udyam.risk.watch-threshold=35 +# ── Aggregate suppression ─────────────────────────────────── +# Fewest borrowers in a cell before its numbers may be published. Below this the +# cell reports "suppressed" rather than a rounded figure, because a district of +# two borrowers and one alert names that borrower as surely as printing them. +udyam.apex.min-cell-size=5 +# The same threshold for a Regional Office session, separated so it can be tuned +# without touching the national one. +# +# It defaults to the SAME value on purpose. Suppression protects against +# re-identifying a borrower from an aggregate, and that risk turns on what the +# reader already knows — a regional officer, supervising four districts and on +# first-name terms with the banks in them, can re-identify from a cell of three +# far more easily than someone reading a national table. So the argument from +# proximity runs the other way: if these two ever diverge, the regional figure +# has a better case for being HIGHER than the national one, not lower. +# +# Lower it only if a supervisor has decided that trade-off deliberately, and +# knows that a small office's sector cells are where it will show. +udyam.apex.min-cell-size.regional-office=5 + # ── Risk & rule engine (ARCHITECTURE Phase D) ─────────────── # Which ScoringStrategy binds. RULE today; MODEL when the LightGBM heads land — # that swap is this line, not a rewrite. @@ -54,9 +78,16 @@ udyam.risk.rule-confidence-ceiling=70 udyam.risk.scheduled=${UDYAM_SCORING_ENABLED:false} udyam.risk.cron=0 0 3 * * * -# Seed the demo portfolio (142 enterprises) on first boot +# Seed the demo portfolio on first boot: six states, one per NABARD zone, each +# carrying 18 enterprises across the five sector packs (108 in all). udyam.seed.enabled=true +# Destructive. When true, the seeder deletes any enterprise not in PortfolioPlan +# along with its runs, alerts, visits, ledger and loan rows. Needed for exactly +# one boot when the portfolio's shape changes — the move from 142 Madhya Pradesh +# enterprises to 108 across six states is such a change. Turn it off again after. +udyam.seed.reshape=${UDYAM_SEED_RESHAPE:false} + # ── Ingestion (ARCHITECTURE Phase B) ──────────────────────── # Off by default: a demo boot must never call a government endpoint. Turn on for # the pilot, or trigger a one-off pass with POST /admin/ingestion/run. @@ -77,6 +108,11 @@ udyam.ingestion.datagov.base-url=https://api.data.gov.in/resource # districts appear. udyam.ingestion.datagov.resource-id=${DATAGOV_RESOURCE_ID:35985678-0d79-46b4-9ed6-6f13308a1d24} udyam.ingestion.datagov.page-limit=1000 +# Page ceiling per (district, commodity). Only reached when a run asks for a deep +# lookback — POST /admin/ingestion/run/AGMARKNET?lookbackWeeks=156 is the one-off +# three-year backfill that gives price_z_score its seasonal mean. 40 pages of +# 1000 rows is ample for three years of one district's arrivals. +udyam.ingestion.datagov.max-pages=40 # Filter names must match the resource's own field names; they differ by resource # version. This resource capitalises them, and — unlike the current-price # resource — matches exactly on the bare name. Appending ".keyword" here returns @@ -89,6 +125,13 @@ udyam.ingestion.datagov.field.commodity=Commodity # side, so without a descending sort a 1000-row page is mostly years-old data and # every commodity reports zero weeks written. udyam.ingestion.datagov.sort-field=Arrival_Date +# data.gov.in meters by quota per window, so the limit is reached by the run and +# not by any one call — once it trips, every remaining pair in that run fails +# together and the whole ingestion records zero rows. Verified 2026-08-11: a +# 62-pair run straight after a 62-pair probe returned 429 on all 62. The backoff +# is long because the window is measured in minutes, not seconds. +udyam.ingestion.datagov.retry-attempts=3 +udyam.ingestion.datagov.retry-backoff-ms=45000 udyam.ingestion.mandi.lookback-weeks=12 # Climate — Open-Meteo reanalysis archive. No key. The archive trails real time, @@ -136,5 +179,16 @@ udyam.forecast.max-drift=0.30 # the forecast cannot reach report NO_FORECAST, and TW-01 skip rather than fire # on a number nothing computed. Set false before any run whose alerts are meant # to be believed. -udyam.forecast.trust-seeded=${UDYAM_TRUST_SEEDED_FORECASTS:true} -udyam.apex.min-cell-size=5 +# Now false. It was true only because ledger coverage reached six enterprises of +# 142, so without the fallback the console had almost no forecasts to show and 34 +# trip-wires of which 5 were real. The seeder now writes five months of ledger for +# every enterprise, band-coherent — a RED enterprise's monthly net genuinely sits +# below its instalment — so the computed forecast is the one worth showing. +udyam.forecast.trust-seeded=${UDYAM_TRUST_SEEDED_FORECASTS:false} + +# Furthest horizon TW-01 may act on. The six-month fan is still forecast, stored +# and drawn; this bounds only what the trip-wire fires from. Past month three the +# band is dominated by compounding uncertainty rather than by anything specific +# to the enterprise — at the 0.75 ceiling P10 is a quarter of P50 for everyone, +# so a worst-of-six rule separates nobody from anybody. +udyam.forecast.tripwire-horizon=3