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:
+29
@@ -0,0 +1,29 @@
|
||||
# Build output
|
||||
target/
|
||||
*.jar
|
||||
*.war
|
||||
|
||||
# Secrets — the data.gov.in key and any local credential override live in the
|
||||
# environment, never in the repo. application-local.properties is the escape
|
||||
# hatch for developers who want them on disk.
|
||||
.env
|
||||
.env.*
|
||||
application-local.properties
|
||||
application-local.yml
|
||||
src/main/resources/application-local.properties
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.settings/
|
||||
.project
|
||||
.classpath
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
# UdyamLinc — Technical Architecture
|
||||
|
||||
Team LincFusion · LincPay Solutions Pvt. Ltd. · NABARD Hackathon
|
||||
|
||||
This document is the build contract for `UdyamLinc-Backend`. It records what the
|
||||
system is, what is built, and what is deliberately deferred.
|
||||
|
||||
---
|
||||
|
||||
## 1. Shape of the system
|
||||
|
||||
```
|
||||
Enterprise PWA / Field Officer Console
|
||||
│
|
||||
▼
|
||||
Spring Boot application (one deployable)
|
||||
│
|
||||
┌──────────────┬──────────────────┼──────────────────┬──────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
enterprise institutional ingestion rules ai
|
||||
(ledger) (CBS, DPD, UPI) (mandi, climate) (trip-wires) (LightGBM)
|
||||
│ │ │ │ │
|
||||
└──────────────┴────────┬─────────┴──────────────────┴──────────────┘
|
||||
▼
|
||||
feature store (tall = truth, wide = read)
|
||||
│
|
||||
▼
|
||||
risk engine (rule-based now, model later)
|
||||
│
|
||||
▼
|
||||
alerts · dashboard · reports · officer playbook
|
||||
```
|
||||
|
||||
### One deployable, not seven services
|
||||
|
||||
The module boundaries above are **Java packages, not microservices**. At pilot
|
||||
scale — four districts, 142 enterprises — separate deployables would buy
|
||||
distributed transactions, cross-service auth and five release pipelines in
|
||||
exchange for nothing. The package boundaries are enforced by convention (no
|
||||
cross-package entity imports; talk to a neighbouring module through its service),
|
||||
so `ingestion` or `ai` can be lifted out later when volume actually justifies it.
|
||||
|
||||
For the same reason there is no separate API gateway component: at this scale the
|
||||
application *is* the gateway.
|
||||
|
||||
---
|
||||
|
||||
## 2. Three principles that decide whether this survives real data
|
||||
|
||||
### 2.1 External feeds are never called in the request path
|
||||
|
||||
Mandi prices, egg rates and climate series are pulled by a scheduled job at
|
||||
02:00, validated, and written to `market_signal` / `climate_signal`. Scoring and
|
||||
login read those tables and nothing else. A government endpoint being down
|
||||
degrades tomorrow's freshness; it never fails a user request.
|
||||
|
||||
### 2.2 The feature store is the only interface to the model
|
||||
|
||||
Ingestion writes signals. Feature engineering turns signals into indicators. The
|
||||
AI layer consumes **only** engineered features. This is what allows the model to
|
||||
be retrained or replaced without touching ingestion, storage or the API.
|
||||
|
||||
The store has two shapes:
|
||||
|
||||
| Table | Shape | Purpose |
|
||||
|---|---|---|
|
||||
| `feature_value` | tall — one row per (enterprise, feature, date) | System of record. Auditable, schema-free as features are added. |
|
||||
| `feature_snapshot` | wide — one row per (enterprise, scoring run) | What the engine and the trainer actually read. |
|
||||
|
||||
Tall alone would mean ~5,700 row reads per scoring pass and far worse for
|
||||
training. Wide alone would mean a migration every time a feature is added.
|
||||
|
||||
### 2.3 Point-in-time correctness
|
||||
|
||||
Every feature row carries **two** timestamps:
|
||||
|
||||
- `valid_for_date` — the date the fact describes
|
||||
- `observed_at` — when we learned it
|
||||
|
||||
A mandi price for 3 March scraped on 7 March must be invisible to a 4 March
|
||||
scoring run. Without this rule, backtests look excellent and production does not
|
||||
reproduce them. This is what makes the claim of "no forward leakage across folds"
|
||||
true rather than aspirational.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
|
||||
### Master
|
||||
|
||||
| Table | Notes |
|
||||
|---|---|
|
||||
| `district` | The join key for every external signal. |
|
||||
| `market` | A mandi, belonging to a district. |
|
||||
| `commodity` | Maize, soya, egg, milk, wheat… |
|
||||
| `sector` | The five risk packs, carrying sub-score weights in basis points. |
|
||||
| `sector_commodity` | Which commodities a sector consumes vs sells. The **input/output** distinction is what makes `margin_squeeze` computable. |
|
||||
| `role` | RBAC for the console. |
|
||||
|
||||
### Enterprise & institutional
|
||||
|
||||
| Table | Notes |
|
||||
|---|---|
|
||||
| `enterprise` | Opaque id, sector, district, lat/long. No owner name, phone, Aadhaar or account number — the no-PII promise is enforced by schema, not policy. |
|
||||
| `ledger_entry` | Three-tap entries. `client_id` is generated on the phone so an offline entry keeps identity through sync. |
|
||||
| `loan_record` | Per loan: sanctioned, outstanding, EMI, due date, **DPD**, bucket. DPD history is the training label. |
|
||||
| `upi_summary` | Consented transaction aggregates. Source pluggable — Account Aggregator (needs FIU registration under Sahamati), PSP settlement reports, or manual. |
|
||||
|
||||
### Signals & features
|
||||
|
||||
| Table | Notes |
|
||||
|---|---|
|
||||
| `market_signal` | `(district, commodity, week)` — modal/min/max price. ~40 rows a day for four districts, not 142 per-enterprise fetches. |
|
||||
| `climate_signal` | `(district, week)` — rainfall, tmax, anomalies vs 30-year normal. |
|
||||
| `feature_definition` | Feature catalogue: code, family, unit, staleness tolerance. |
|
||||
| `feature_value` | Tall store, with `valid_for_date` + `observed_at`. |
|
||||
| `feature_snapshot` | Wide store, one per enterprise per run. |
|
||||
|
||||
### Scoring & decision
|
||||
|
||||
| Table | Notes |
|
||||
|---|---|
|
||||
| `scoring_run` | One pass. Carries `model_version` and `source` (`SERVER` \| `ON_DEVICE`) so a historic alert traces to the exact engine that raised it. |
|
||||
| `forecast_point` | P10 / P50 / P90 and the obligation due, per month, h = 1…6. |
|
||||
| `stress_score` | Composite 0–100 plus the five stored sub-scores, `engine`, `confidence`. |
|
||||
| `reason_code` | SHAP driver as a sentence in both languages. Stored, not regenerated, so the wording an officer acted on is preserved. |
|
||||
| `tripwire_rule` | Configurable rule: SpEL expression over the feature snapshot. |
|
||||
| `risk_alert` | Raised by the model or by a rule. References the rule and the snapshot, so it is reproducible. |
|
||||
| `field_visit` | Playbook intervention and its outcome — the feedback edge of the loop. |
|
||||
|
||||
### Audit
|
||||
|
||||
`api_log` (outbound calls to external feeds), `ingestion_run` (scheduler results,
|
||||
counts, failures), and `scoring_run` doubling as the model prediction log.
|
||||
|
||||
---
|
||||
|
||||
## 4. External data — provenance
|
||||
|
||||
| Signal | Source | Access | Cadence |
|
||||
|---|---|---|---|
|
||||
| Mandi prices | Agmarknet, via the data.gov.in Open Government Data API | API key, JSON | Daily |
|
||||
| Egg realisation | NECC (e2necc.com) | No API — scrape the daily city rate table | Daily |
|
||||
| Rainfall, temperature | NASA POWER or Open-Meteo | Free, no key, long history for anomalies | Daily |
|
||||
| District advisories | IMD Agromet | District-level advisory text | Tue / Fri |
|
||||
| Milk procurement rate | State dairy federation | No API anywhere — MoU or admin entry | Monthly |
|
||||
| Institutional extract | Lender CBS / LMS | SFTP CSV or read-only DB view | Nightly |
|
||||
| UPI aggregates | Account Aggregator (RBI / Sahamati) | FIU registration + consent artefact | Weekly |
|
||||
|
||||
IMD supplies the advisory; NASA POWER or Open-Meteo supplies the numbers, because
|
||||
anomalies need decades of history that live endpoints do not serve.
|
||||
|
||||
### Feature derivations
|
||||
|
||||
Raw prices are not features. An absolute maize price of ₹2,140 means nothing.
|
||||
|
||||
| Feature | Derivation |
|
||||
|---|---|
|
||||
| `feed_cost_change` | modal price vs 8-week trailing mean |
|
||||
| `output_price_change` | same, on the sale-side commodity |
|
||||
| `margin_squeeze` | input change − output change |
|
||||
| `price_z_score` | (current − 3yr seasonal mean) / stdev |
|
||||
| `rainfall_anomaly` | season-to-date vs 30yr normal |
|
||||
| `heat_anomaly` | days above threshold vs normal |
|
||||
| `cash_flow_change` | ledger net position vs trailing mean |
|
||||
| `emi_delay` | DPD from `loan_record` |
|
||||
| `inventory_days` | stock entries vs sales velocity |
|
||||
| `upi_inflow_change` | 8-week rolling inflow delta |
|
||||
| `feed_staleness_days` | age of the newest observation — guards all of the above |
|
||||
|
||||
### Degradation rules
|
||||
|
||||
- Carry the last known value forward, stamped with `feed_staleness_days`.
|
||||
- **Trip-wires refuse to fire on stale data.** A rule firing on a three-week-old
|
||||
price is worse than no rule.
|
||||
- Missing shock features fall back to the sector's seasonal prior. The forecast
|
||||
widens — P10 drops — which is the honest response to knowing less.
|
||||
- Scoring never blocks on an external feed. The institutional record and the
|
||||
ledger are always present; market data is an enhancement.
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk and rules
|
||||
|
||||
The **risk engine** is rule-based first: a sector-weighted combination of the five
|
||||
sub-score families, with weights held in the `sector` table so tuning needs no
|
||||
deploy. It sits behind a `ScoringStrategy` interface, so the LightGBM engine
|
||||
replaces it as a config change.
|
||||
|
||||
The **rule engine** is deliberately independent. `tripwire_rule` rows carry a SpEL
|
||||
expression evaluated against the feature snapshot:
|
||||
|
||||
```
|
||||
emi_delay > 30 and cash_flow_change < -0.40
|
||||
```
|
||||
|
||||
Business teams edit rows; nobody deploys. SpEL rather than Drools — seven rules do
|
||||
not justify a rules platform.
|
||||
|
||||
A trip-wire alert survives a model retrain. That is the entire point: every alert
|
||||
stays auditable and explainable independently of whatever the model currently
|
||||
believes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Build status
|
||||
|
||||
| Area | State |
|
||||
|---|---|
|
||||
| Field officer console — 8 screens | ✅ Built |
|
||||
| Enterprise PWA — 4 tabs, offline, Hindi-first | ✅ Built |
|
||||
| Ledger + offline queue + sync | ✅ Built |
|
||||
| Auth, JWT, two scoped surfaces | ✅ Built |
|
||||
| Serving entities, repositories, services | ✅ Built |
|
||||
| Master data, controllers, seeder | ◐ Phase A |
|
||||
| Ingestion (Agmarknet, climate, scheduler) | ✅ Phase B — see §6.1 |
|
||||
| Feature store + derivations | ✅ Phase C — see §6.2 |
|
||||
| Risk engine + rule engine + admin API | ✅ Phase D — see §6.3 |
|
||||
| Console wire-up: rules screen, market panel, freshness | ✅ Phase E — see §6.4 |
|
||||
| LightGBM quantile heads + SHAP | ○ R1 milestone |
|
||||
|
||||
Phases B, C and D each terminate in a table you can inspect, so each is testable
|
||||
without any of the machine learning existing.
|
||||
|
||||
### 6.1 What Phase B delivered
|
||||
|
||||
Package `com.lincfusion.udyamlinc.ingestion`.
|
||||
|
||||
| Piece | Notes |
|
||||
|---|---|
|
||||
| `DataGovClient` | Agmarknet via data.gov.in. Case-insensitive field reads, because the resource's own casing changes between versions. Returns `null` on a failed call and an empty list on a filter that matched nothing — an outage and a typo must not look alike. |
|
||||
| `OpenMeteoClient` | Reanalysis archive for the district centroid. No key. |
|
||||
| `MandiIngestionService` | Folds daily arrivals into one `market_signal` per (district, commodity, ISO week). Upserts, so re-running corrects a partial week. |
|
||||
| `ClimateIngestionService` | Weekly `climate_signal` with the 30-year normal alongside, and `climate_normal` computed once per district from a single baseline request. |
|
||||
| `IngestionScheduler` | The 02:00 cron. Off by default; one pass at a time; every outcome — including a skip — lands in `ingestion_run`. |
|
||||
| `IngestionController` | `/admin/ingestion/{runs,calls,signals/market,signals/climate,freshness}`, plus `POST /run` and `/run/{source}`. Reads open to any officer, triggers supervisor-only under the existing `/admin/**` rule. |
|
||||
|
||||
Both signal tables carry `valid_for_date` + `observed_at`, and each repository
|
||||
exposes a `findTrailing(...)` that filters on both — the point-in-time window
|
||||
Phase C's derivations will read. `/admin/ingestion/freshness` is the staleness
|
||||
number Phase D's trip-wires must consult before firing.
|
||||
|
||||
**Configuration.** `udyam.ingestion.enabled` is `false` by default so a demo boot
|
||||
never calls out. `udyam.ingestion.datagov.api-key` (env `DATAGOV_API_KEY`) is
|
||||
required for Agmarknet; without it the run records `SKIPPED` rather than failing.
|
||||
Climate needs no key.
|
||||
|
||||
**Still deferred from §4.** NECC egg rates (no API — needs an HTML scrape), IMD
|
||||
Agromet advisories, and the dairy federation rate (no API anywhere). Their
|
||||
commodities are seeded and their `source` values reserved; nothing pulls them yet.
|
||||
|
||||
### 6.2 What Phase C delivered
|
||||
|
||||
Package `com.lincfusion.udyamlinc.features`, plus the two institutional tables of
|
||||
§3 the derivations read from (`loan_record`, `upi_summary`).
|
||||
|
||||
| Piece | Notes |
|
||||
|---|---|
|
||||
| `feature_definition` | The catalogue: family, unit, source kind, direction, and `staleness_tolerance_days` — the column a trip-wire consults before it is allowed to fire. Seeded idempotently at boot. |
|
||||
| `feature_value` | The tall store. A feature that cannot be computed is written as **null with a `missing_reason`**, never skipped — `NO_SIGNAL`, `NO_LEDGER`, `NO_CBS`, `NO_CONSENT`, `NO_FORECAST`, `INSUFFICIENT_HISTORY`. "We did not know" and "we never looked" are different facts. |
|
||||
| `feature_snapshot` | The wide store, one per (enterprise, as-of date), carrying `feature_count` / `missing_count` / `max_staleness_days` so a snapshot mostly made of holes cannot read as confident. `asFeatureMap()` exposes the same values under their snake_case codes — the shape a SpEL rule is written against. |
|
||||
| `FeatureEngineeringService` | All fourteen derivations. Every read is point-in-time on both `valid_for_date` and `observed_at`, so recomputing last month uses only what was knowable last month. |
|
||||
| `FeatureController` | `/admin/features/{definitions,snapshot/{id},values/{id},coverage}` and `POST /compute`, `POST /compute/{id}`. `coverage` reports the populated share per feature — a feature at 4 % coverage is a column, not a feature. |
|
||||
|
||||
**Catalogue.** §4's eleven, plus three the seeded trip-wires already named:
|
||||
`dscr_p10` (worst month of `min(p10 / obligation)` over the latest run),
|
||||
`days_since_last_entry`, and `emi_to_inflow`. A rule referring to a feature
|
||||
nobody computes is a rule that silently never fires. These three also give the
|
||||
OBLIGATION_LOAD family the inputs it otherwise lacked.
|
||||
|
||||
**Demo inputs.** The seeder now writes one `loan_record` per enterprise with DPD
|
||||
tracking the band, so `emi_delay` and `emi_to_inflow` are real without a CBS
|
||||
feed. `upi_summary` has an entity and a point-in-time query but no source until
|
||||
Account Aggregator registration — `upi_inflow_change` therefore resolves to null
|
||||
with `NO_CONSENT`, which is the honest answer and exercises the degradation path.
|
||||
Price and climate features stay null until Phase B has actually run.
|
||||
|
||||
### 6.3 What Phase D delivered
|
||||
|
||||
Packages `rules` and `risk`.
|
||||
|
||||
| Piece | Notes |
|
||||
|---|---|
|
||||
| `RuleEvaluator` | SpEL over the feature rows, in a `SimpleEvaluationContext` restricted to property access — these expressions are authored by business users, so no method calls or bean lookups. Compiled expressions cached by rule code **and version**, so an edit invalidates its own entry; the admin write endpoints also clear the cache. |
|
||||
| `RuleOutcome` | Every rule returns FIRED / NOT_FIRED / SKIPPED_STALE / SKIPPED_MISSING / SKIPPED_SECTOR / ERROR. "No alert because the enterprise is fine" and "no alert because the feed has been down a fortnight" must never look the same. |
|
||||
| `ScoringStrategy` | The seam the model arrives through. `udyam.risk.engine` binds the implementation; LightGBM later is that one line. Implementations read features and nothing else — no reaching back into the signal tables. |
|
||||
| `RuleBasedScoringStrategy` | Five families onto 0–100, combined with the sector's basis-point weights. Each family takes its **worst** signal, not the mean — averaging lets a calm dimension mask an acute one, and the acute one is where the officer needs to go. |
|
||||
| `RiskEngineService` | Compute features → score → evaluate trip-wires against the **same feature rows, never the score** → persist score, top-3 reason codes, alerts. |
|
||||
| `RiskEngineController` | `POST /admin/scoring/run`, `/run/{id}`, `GET /admin/scoring/runs`, and `GET /admin/scoring/explain/{id}` — every rule's outcome with no writes. |
|
||||
| `ScoringScheduler` | 03:00, an hour behind ingestion so it scores what that job wrote. Off by default. |
|
||||
|
||||
**The staleness gate.** Before any expression runs, each of a rule's
|
||||
`requiredFeatures` must have a value and be within the **stricter** of the rule's
|
||||
`maxStalenessDays` and the catalogue's per-feature tolerance — a supervisor
|
||||
tightening a rule must not be overridden by a generous default. This is the
|
||||
promise of §4's degradation rules, enforced rather than documented.
|
||||
|
||||
**Band versus score.** A fired trip-wire escalates the band; it never rewrites
|
||||
the score. They answer different questions — "how stressed is this enterprise"
|
||||
and "has a specific agreed line been crossed" — and an audit needs both.
|
||||
A family with no usable feature falls back to `udyam.risk.family-prior` rather
|
||||
than scoring zero, because zero reads as "safe here" when the truth is "unknown",
|
||||
and each family on a prior costs confidence.
|
||||
|
||||
**What the seeded rules can actually do today.** TW-01 (`dscr_p10`) and TW-03
|
||||
(`days_since_last_entry`) evaluate from seeded data. TW-04 (`emi_to_inflow`)
|
||||
evaluates once loan records exist, which the seeder now writes. TW-02
|
||||
(`upi_inflow_change`) and TW-05 (`price_z_score`, `heat_anomaly`) will report
|
||||
SKIPPED_MISSING until Account Aggregator consent and Phase B ingestion
|
||||
respectively — visibly skipped, which is the intended behaviour rather than a
|
||||
silent no-op.
|
||||
|
||||
**Not built here.** The quantile forecast is still the seeded path; this engine
|
||||
scores and alerts but does not produce `forecast_point` rows. `dscr_p10` reads
|
||||
whatever the latest run stored. Generating forecasts is part of the R1 model
|
||||
milestone.
|
||||
|
||||
### 6.4 What Phase E delivered
|
||||
|
||||
In `UdyamLinc-UI`, two console screens and one piece of chrome — all three about
|
||||
provenance, because the failure mode that matters is silent. A score computed
|
||||
from a three-week-old mandi price looks exactly like one computed this morning.
|
||||
|
||||
| Piece | Notes |
|
||||
|---|---|
|
||||
| `features/officer/DataFeedsPage.tsx` | `/officer/feeds` — freshness per district first, then the mandi panel, the rainfall/heat panel with the 30-year normal beside each observation, and the ingestion run log. A skipped run appears as a row, so "nothing ran last night" never reads as "it ran and found nothing". |
|
||||
| `features/officer/RulesPage.tsx` | `/officer/rules` — every trip-wire with its expression and tolerance, its outcome for a chosen enterprise, and the feature snapshot underneath with each null's reason. A banner counts rules that could not run. |
|
||||
| `FreshnessChip` in `OfficerShell` | Data age in the header on every screen, amber past ten days, linking to the feeds page. |
|
||||
| `lib/demo/feeds.ts` | Demo fallback that applies the **same two guards in the same order** as `RuleEvaluator` — missing, then stale, then the expression — so the offline demo cannot claim a rule fired that the real engine would have skipped. |
|
||||
|
||||
Both screens follow the house pattern: `useQuery` against the real endpoint,
|
||||
in-file demo fallback, and a "Demo data" pill when the backend is unreachable.
|
||||
`npm run build` and `oxlint` are clean.
|
||||
|
||||
**Deliberately not added:** rule *editing* from the console. The backend accepts
|
||||
`POST`/`PUT /admin/rules` and bumps the version on every edit, but a supervisor
|
||||
changing a live threshold from a demo build with no audit trail in front of it is
|
||||
a worse idea than the round trip through an API client.
|
||||
|
||||
---
|
||||
|
||||
## 7. Explicitly not being built yet
|
||||
|
||||
Kafka, Neo4j, Elasticsearch, Keycloak, MinIO, and separate deployables. All appear
|
||||
on the submission's technology slide; none earn their keep at four districts.
|
||||
Postgres plus a scheduled job carries the pilot comfortably. Kafka arrives when
|
||||
ingestion volume or a second consumer demands it — not before.
|
||||
@@ -0,0 +1,137 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.1</version>
|
||||
<relativePath />
|
||||
</parent>
|
||||
|
||||
<groupId>com.lincfusion</groupId>
|
||||
<artifactId>udyamlinc-backend</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>udyamlinc-backend</name>
|
||||
<description>UdyamLinc — predictive cash-flow intelligence and early warning for rural microenterprises (NABARD Hackathon)</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<!-- Web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Security -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JPA -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- PostgreSQL -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Jackson -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.36</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>udyamlinc-backend</finalName>
|
||||
|
||||
<plugins>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<mainClass>com.lincfusion.udyamlinc.UdyamLincApplication</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<release>17</release>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.36</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lincfusion.udyamlinc;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* UdyamLinc — predictive cash-flow intelligence and early warning for rural
|
||||
* microenterprises. NABARD Hackathon submission by Team LincFusion.
|
||||
*
|
||||
* <p>This service is the delivery layer of the architecture on the deck: it
|
||||
* serves the field officer console and the enterprise PWA from stored forecast
|
||||
* runs and scores. The AI/ML layer (LightGBM quantile heads, SHAP) and the
|
||||
* ingestion pipeline are separate components and are not implemented here.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class UdyamLincApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(UdyamLincApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.lincfusion.udyamlinc.admin;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ApiResponse;
|
||||
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
||||
import com.lincfusion.udyamlinc.master.*;
|
||||
import com.lincfusion.udyamlinc.rules.RuleEvaluator;
|
||||
import com.lincfusion.udyamlinc.rules.TripWireRule;
|
||||
import com.lincfusion.udyamlinc.rules.TripWireRuleRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Configuration surface for supervisors: sector weights and trip-wire rules.
|
||||
*
|
||||
* <p>These are the two things the deck promises business teams can change
|
||||
* without a deploy. Reads are open to any officer; writes require a supervisor —
|
||||
* enforced by path and method in {@code SecurityConfig}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final SectorRepository sectorRepository;
|
||||
private final DistrictRepository districtRepository;
|
||||
private final CommodityRepository commodityRepository;
|
||||
private final TripWireRuleRepository ruleRepository;
|
||||
private final RuleEvaluator ruleEvaluator;
|
||||
|
||||
// ── masters ─────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/sectors")
|
||||
public ApiResponse<List<Sector>> sectors() {
|
||||
return ApiResponse.ok(sectorRepository.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/districts")
|
||||
public ApiResponse<List<District>> districts() {
|
||||
return ApiResponse.ok(districtRepository.findByActiveTrue());
|
||||
}
|
||||
|
||||
@GetMapping("/commodities")
|
||||
public ApiResponse<List<Commodity>> commodities() {
|
||||
return ApiResponse.ok(commodityRepository.findByActiveTrue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retune a sector pack. Weights are basis points and must sum to 10000 —
|
||||
* rejected otherwise, because a pack that does not sum silently rescales
|
||||
* every score in that sector.
|
||||
*/
|
||||
@PutMapping("/sectors/{code}/weights")
|
||||
public ApiResponse<Sector> updateWeights(@PathVariable String code, @RequestBody Sector body) {
|
||||
Sector sector = sectorRepository.findByCode(code)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No sector " + code));
|
||||
sector.setWLiquidity(body.getWLiquidity());
|
||||
sector.setWBehavioural(body.getWBehavioural());
|
||||
sector.setWTrendVolatility(body.getWTrendVolatility());
|
||||
sector.setWObligationLoad(body.getWObligationLoad());
|
||||
sector.setWExternalShock(body.getWExternalShock());
|
||||
if (!sector.weightsBalanced()) {
|
||||
throw new IllegalArgumentException("Sector weights must sum to 10000 basis points");
|
||||
}
|
||||
return ApiResponse.ok("Weights updated", sectorRepository.save(sector));
|
||||
}
|
||||
|
||||
// ── trip-wire rules ─────────────────────────────────────
|
||||
|
||||
@GetMapping("/rules")
|
||||
public ApiResponse<List<TripWireRule>> rules() {
|
||||
return ApiResponse.ok(ruleRepository.findAll());
|
||||
}
|
||||
|
||||
@PostMapping("/rules")
|
||||
public ApiResponse<TripWireRule> create(
|
||||
@Valid @RequestBody TripWireRule rule,
|
||||
@AuthenticationPrincipal String actor) {
|
||||
rule.setId(null);
|
||||
rule.setVersion(1);
|
||||
rule.setUpdatedBy(actor);
|
||||
rule.setUpdatedAt(LocalDateTime.now());
|
||||
ruleEvaluator.invalidate();
|
||||
return ApiResponse.ok("Rule created", ruleRepository.save(rule));
|
||||
}
|
||||
|
||||
/** Editing bumps the version, so an old alert can still name the rule that fired. */
|
||||
@PutMapping("/rules/{code}")
|
||||
public ApiResponse<TripWireRule> update(
|
||||
@PathVariable String code,
|
||||
@RequestBody TripWireRule body,
|
||||
@AuthenticationPrincipal String actor) {
|
||||
TripWireRule rule = ruleRepository.findByCode(code)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No rule " + code));
|
||||
rule.setLabel(body.getLabel());
|
||||
rule.setLabelHi(body.getLabelHi());
|
||||
rule.setExpression(body.getExpression());
|
||||
rule.setThresholdText(body.getThresholdText());
|
||||
rule.setSeverity(body.getSeverity());
|
||||
rule.setSectorCode(body.getSectorCode());
|
||||
rule.setRequiredFeatures(body.getRequiredFeatures());
|
||||
rule.setMaxStalenessDays(body.getMaxStalenessDays());
|
||||
rule.setMessageEn(body.getMessageEn());
|
||||
rule.setMessageHi(body.getMessageHi());
|
||||
rule.setActive(body.getActive());
|
||||
rule.setVersion(rule.getVersion() == null ? 1 : rule.getVersion() + 1);
|
||||
rule.setUpdatedBy(actor);
|
||||
rule.setUpdatedAt(LocalDateTime.now());
|
||||
ruleEvaluator.invalidate();
|
||||
return ApiResponse.ok("Rule updated", ruleRepository.save(rule));
|
||||
}
|
||||
|
||||
@DeleteMapping("/rules/{code}")
|
||||
public ApiResponse<String> deactivate(@PathVariable String code, @AuthenticationPrincipal String actor) {
|
||||
TripWireRule rule = ruleRepository.findByCode(code)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No rule " + code));
|
||||
rule.setActive(false);
|
||||
rule.setUpdatedBy(actor);
|
||||
rule.setUpdatedAt(LocalDateTime.now());
|
||||
ruleRepository.save(rule);
|
||||
ruleEvaluator.invalidate();
|
||||
return ApiResponse.ok("Rule deactivated", code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.lincfusion.udyamlinc.app;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ApiResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.ConsentRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.LedgerEntryRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.AppHomeResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.SyncResultResponse;
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.enterprise.LedgerEntry;
|
||||
import com.lincfusion.udyamlinc.risk.RiskAlert;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The enterprise PWA API.
|
||||
*
|
||||
* <p>The enterprise id always comes from the token's subject, never from the
|
||||
* request — an owner cannot read another enterprise's record by changing a path
|
||||
* parameter, because there is no path parameter to change.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/app")
|
||||
@RequiredArgsConstructor
|
||||
public class EnterpriseAppController {
|
||||
|
||||
private final EnterpriseAppService appService;
|
||||
|
||||
@GetMapping("/home")
|
||||
public ApiResponse<AppHomeResponse> home(@AuthenticationPrincipal String enterpriseId) {
|
||||
return ApiResponse.ok(appService.home(enterpriseId));
|
||||
}
|
||||
|
||||
@GetMapping("/ledger")
|
||||
public ApiResponse<List<LedgerEntry>> ledger(@AuthenticationPrincipal String enterpriseId) {
|
||||
return ApiResponse.ok(appService.ledger(enterpriseId));
|
||||
}
|
||||
|
||||
/** Drain the phone's offline queue. Safe to call twice with the same batch. */
|
||||
@PostMapping("/ledger/sync")
|
||||
public ApiResponse<SyncResultResponse> sync(
|
||||
@AuthenticationPrincipal String enterpriseId,
|
||||
@Valid @RequestBody List<LedgerEntryRequest> batch) {
|
||||
return ApiResponse.ok("Synced", appService.sync(enterpriseId, batch));
|
||||
}
|
||||
|
||||
@GetMapping("/alerts")
|
||||
public ApiResponse<List<RiskAlert>> alerts(@AuthenticationPrincipal String enterpriseId) {
|
||||
return ApiResponse.ok(appService.alerts(enterpriseId));
|
||||
}
|
||||
|
||||
@PutMapping("/consent")
|
||||
public ApiResponse<Enterprise> consent(
|
||||
@AuthenticationPrincipal String enterpriseId,
|
||||
@RequestBody ConsentRequest request) {
|
||||
return ApiResponse.ok(
|
||||
request.active() ? "Consent recorded" : "Consent withdrawn",
|
||||
appService.setConsent(enterpriseId, request.active()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.lincfusion.udyamlinc.app;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.LedgerEntryRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.AppHomeResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.SyncResultResponse;
|
||||
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.master.Sector;
|
||||
import com.lincfusion.udyamlinc.master.SectorRepository;
|
||||
import com.lincfusion.udyamlinc.risk.ForecastPointRepository;
|
||||
import com.lincfusion.udyamlinc.risk.RiskAlert;
|
||||
import com.lincfusion.udyamlinc.risk.RiskAlertRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The enterprise PWA's server side.
|
||||
*
|
||||
* <p>Note what this deliberately does <em>not</em> do: it never fails a ledger
|
||||
* write because of connectivity. The phone owns the entry, generates its id and
|
||||
* queues it; this service only accepts the queue and de-duplicates it.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EnterpriseAppService {
|
||||
|
||||
private final EnterpriseRepository enterpriseRepository;
|
||||
private final LedgerEntryRepository ledgerRepository;
|
||||
private final ForecastPointRepository pointRepository;
|
||||
private final RiskAlertRepository alertRepository;
|
||||
private final SectorRepository sectorRepository;
|
||||
|
||||
public AppHomeResponse home(String enterpriseId) {
|
||||
Enterprise enterprise = requireEnterprise(enterpriseId);
|
||||
RiskAlert alert = alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId).stream()
|
||||
.filter(a -> !"CLOSED".equals(a.getState()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
List<LedgerEntry> today = ledgerRepository.findByEnterpriseIdAndEntryDate(enterpriseId, LocalDate.now());
|
||||
Map<String, String> advice = adviceFor(sectorCode(enterprise));
|
||||
|
||||
return new AppHomeResponse(
|
||||
enterprise,
|
||||
alert,
|
||||
pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId),
|
||||
sum(today, "IN"),
|
||||
sum(today, "OUT"),
|
||||
advice.get("en"),
|
||||
advice.get("hi"),
|
||||
lastSyncedAt(enterpriseId));
|
||||
}
|
||||
|
||||
public List<LedgerEntry> ledger(String enterpriseId) {
|
||||
return ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId);
|
||||
}
|
||||
|
||||
public List<RiskAlert> alerts(String enterpriseId) {
|
||||
return alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the phone's offline queue. Idempotent on {@code clientId}, so a queue
|
||||
* submitted twice after a flaky reconnect cannot double-count.
|
||||
*/
|
||||
@Transactional
|
||||
public SyncResultResponse sync(String enterpriseId, List<LedgerEntryRequest> batch) {
|
||||
requireEnterprise(enterpriseId);
|
||||
int accepted = 0;
|
||||
int duplicates = 0;
|
||||
|
||||
for (LedgerEntryRequest r : batch) {
|
||||
if (ledgerRepository.findByClientId(r.clientId()).isPresent()) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
ledgerRepository.save(LedgerEntry.builder()
|
||||
.clientId(r.clientId())
|
||||
.enterpriseId(enterpriseId)
|
||||
.kind(r.kind().toUpperCase())
|
||||
.amount(r.amount())
|
||||
.category(r.category())
|
||||
.note(r.note())
|
||||
.entryDate(r.entryDate() == null ? LocalDate.now() : LocalDate.parse(r.entryDate()))
|
||||
.syncedAt(LocalDateTime.now())
|
||||
.build());
|
||||
accepted++;
|
||||
}
|
||||
return new SyncResultResponse(accepted, duplicates, LocalDateTime.now().toString());
|
||||
}
|
||||
|
||||
/** Consent is revocable and purpose-bound; withdrawing it stops scoring. */
|
||||
@Transactional
|
||||
public Enterprise setConsent(String enterpriseId, boolean active) {
|
||||
Enterprise enterprise = requireEnterprise(enterpriseId);
|
||||
enterprise.setConsentActive(active);
|
||||
enterprise.setConsentUpdatedAt(LocalDateTime.now());
|
||||
return enterpriseRepository.save(enterprise);
|
||||
}
|
||||
|
||||
private String lastSyncedAt(String enterpriseId) {
|
||||
return ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream()
|
||||
.map(LedgerEntry::getSyncedAt)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.map(LocalDateTime::toString)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private BigDecimal sum(List<LedgerEntry> entries, String kind) {
|
||||
return entries.stream()
|
||||
.filter(e -> kind.equals(e.getKind()))
|
||||
.map(LedgerEntry::getAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
private String sectorCode(Enterprise e) {
|
||||
return sectorRepository.findById(e.getSectorId()).map(Sector::getCode).orElse("RURAL_RETAIL");
|
||||
}
|
||||
|
||||
private Enterprise requireEnterprise(String enterpriseId) {
|
||||
return enterpriseRepository.findByEnterpriseId(enterpriseId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No enterprise " + enterpriseId));
|
||||
}
|
||||
|
||||
/** Sector-specific suggestion shown on the Home tab, in both languages. */
|
||||
private Map<String, String> adviceFor(String sector) {
|
||||
return switch (sector) {
|
||||
case "POULTRY" -> Map.of(
|
||||
"en", "Buy feed now — the rate is up 18% and may rise further.",
|
||||
"hi", "अभी दाना ख़रीद लें — रेट 18% बढ़ा है और आगे और बढ़ सकता है।");
|
||||
case "DAIRY" -> Map.of(
|
||||
"en", "Stock fodder now and press the society for faster payment.",
|
||||
"hi", "चारा अभी भर लें और समिति से भुगतान जल्दी माँगें।");
|
||||
case "FOOD_PROC" -> Map.of(
|
||||
"en", "Buy raw material in bulk and hold buyers to 15-day settlement.",
|
||||
"hi", "कच्चा माल थोक में लें और खरीदार से 15 दिन में भुगतान तय करें।");
|
||||
case "HANDICRAFTS" -> Map.of(
|
||||
"en", "Add a second buyer so sales do not depend on one account.",
|
||||
"hi", "तैयार माल की बिक्री के लिए दूसरा खरीदार भी जोड़ें।");
|
||||
default -> Map.of(
|
||||
"en", "Reduce credit sales and collect daily over UPI.",
|
||||
"hi", "उधार बिक्री कम करें और रोज़ UPI से वसूली करें।");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.lincfusion.udyamlinc.auth;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ApiResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.AppLoginRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.OfficerLoginRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseSessionResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.OfficerSessionResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** The only unauthenticated surface. */
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@PostMapping("/officer-login")
|
||||
public ApiResponse<OfficerSessionResponse> officerLogin(@Valid @RequestBody OfficerLoginRequest request) {
|
||||
return ApiResponse.ok("Signed in", authService.officerLogin(request));
|
||||
}
|
||||
|
||||
@PostMapping("/app-login")
|
||||
public ApiResponse<EnterpriseSessionResponse> appLogin(@Valid @RequestBody AppLoginRequest request) {
|
||||
return ApiResponse.ok("Signed in", authService.appLogin(request));
|
||||
}
|
||||
|
||||
/** Liveness probe, also used by the UI to decide whether to fall back to demo data. */
|
||||
@GetMapping("/health")
|
||||
public ApiResponse<Map<String, String>> health() {
|
||||
return ApiResponse.ok(Map.of("service", "udyamlinc-backend", "status", "UP"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.lincfusion.udyamlinc.auth;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.AppLoginRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.OfficerLoginRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseSessionResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.OfficerSessionResponse;
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
|
||||
import com.lincfusion.udyamlinc.master.Sector;
|
||||
import com.lincfusion.udyamlinc.master.SectorRepository;
|
||||
import com.lincfusion.udyamlinc.security.JwtUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Sign-in for both surfaces: resolve the user, verify the secret, issue a token
|
||||
* scoped to exactly what that user may read.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final UdyamUserRepository userRepository;
|
||||
private final EnterpriseRepository enterpriseRepository;
|
||||
private final SectorRepository sectorRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@Transactional
|
||||
public OfficerSessionResponse officerLogin(OfficerLoginRequest request) {
|
||||
UdyamUser user = userRepository.findByUsernameIgnoreCase(request.username().trim())
|
||||
.filter(u -> "OFFICER".equalsIgnoreCase(u.getScope()))
|
||||
.orElseThrow(() -> new BadCredentialsException("No field officer is registered with that username"));
|
||||
|
||||
verify(user, request.password());
|
||||
touch(user);
|
||||
|
||||
String token = jwtUtil.generateOfficerToken(user.getUsername(), user.getRole(), user.getBlocks());
|
||||
List<String> blocks = user.getBlocks() == null || user.getBlocks().isBlank()
|
||||
? List.of()
|
||||
: Arrays.stream(user.getBlocks().split(",")).map(String::trim).toList();
|
||||
|
||||
return new OfficerSessionResponse(
|
||||
token, "Bearer", user.getUsername(), user.getFullName(), user.getRole(), blocks, user.getBranch());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public EnterpriseSessionResponse appLogin(AppLoginRequest request) {
|
||||
String enterpriseId = request.enterpriseId().trim().toUpperCase();
|
||||
|
||||
Enterprise enterprise = enterpriseRepository.findByEnterpriseId(enterpriseId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No enterprise is enrolled as " + enterpriseId));
|
||||
if (!Boolean.TRUE.equals(enterprise.getConsentActive())) {
|
||||
throw new BadCredentialsException("Consent has been withdrawn for this enterprise");
|
||||
}
|
||||
|
||||
UdyamUser user = userRepository.findByEnterpriseIdAndScopeIgnoreCase(enterpriseId, "ENTERPRISE")
|
||||
.orElseThrow(() -> new BadCredentialsException("No app login exists for this enterprise"));
|
||||
|
||||
verify(user, request.pin());
|
||||
touch(user);
|
||||
|
||||
String sector = sectorRepository.findById(enterprise.getSectorId())
|
||||
.map(Sector::getCode)
|
||||
.orElse("RURAL_RETAIL");
|
||||
|
||||
String token = jwtUtil.generateEnterpriseToken(enterprise.getEnterpriseId(), sector);
|
||||
return new EnterpriseSessionResponse(
|
||||
token, "Bearer", enterprise.getEnterpriseId(), enterprise.getName(), enterprise.getNameHi(),
|
||||
sector, enterprise.getBlock(), "hi");
|
||||
}
|
||||
|
||||
private void verify(UdyamUser user, String secret) {
|
||||
if (!Boolean.TRUE.equals(user.getActive())) {
|
||||
throw new BadCredentialsException("This login is deactivated");
|
||||
}
|
||||
if (user.getPassword() == null || !passwordEncoder.matches(secret, user.getPassword())) {
|
||||
throw new BadCredentialsException("Invalid credentials");
|
||||
}
|
||||
}
|
||||
|
||||
private void touch(UdyamUser user) {
|
||||
user.setLastLoginAt(LocalDateTime.now());
|
||||
userRepository.save(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.lincfusion.udyamlinc.auth;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A console or app login. Deliberately thin: no phone, address or document
|
||||
* number. The only identity here is a work handle for an officer, or an
|
||||
* enterprise id for an owner.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "udyam_user", indexes = {
|
||||
@Index(name = "ix_user_username", columnList = "username", unique = true),
|
||||
@Index(name = "ix_user_enterprise", columnList = "enterpriseId"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class UdyamUser {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
private String username;
|
||||
|
||||
/** Display handle for the console header. Not a customer name. */
|
||||
@Column(length = 96)
|
||||
private String fullName;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String password;
|
||||
|
||||
/** Denormalised role code. {@code roleId} is the authority once RBAC lands. */
|
||||
@Column(nullable = false, length = 32)
|
||||
private String role;
|
||||
|
||||
private Long roleId;
|
||||
|
||||
/** OFFICER | ENTERPRISE — decides which API surface the token opens. */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String scope;
|
||||
|
||||
/** Set only for scope=ENTERPRISE logins. */
|
||||
@Column(length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
/** CSV of blocks an officer covers. */
|
||||
@Column(length = 255)
|
||||
private String blocks;
|
||||
|
||||
@Column(length = 96)
|
||||
private String branch;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Boolean active;
|
||||
|
||||
private LocalDateTime lastLoginAt;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
if (active == null) active = Boolean.TRUE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lincfusion.udyamlinc.auth;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UdyamUserRepository extends JpaRepository<UdyamUser, Long> {
|
||||
|
||||
Optional<UdyamUser> findByUsernameIgnoreCase(String username);
|
||||
|
||||
Optional<UdyamUser> findByEnterpriseIdAndScopeIgnoreCase(String enterpriseId, String scope);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.lincfusion.udyamlinc.common;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* The single response envelope every endpoint returns: {@code {success, message, data}}.
|
||||
* The UI's api.ts unwraps {@code .data} — keep this shape stable.
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private final boolean success;
|
||||
private final String message;
|
||||
private final T data;
|
||||
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
return new ApiResponse<>(true, "OK", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> ok(String message, T data) {
|
||||
return new ApiResponse<>(true, message, data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> fail(String message) {
|
||||
return new ApiResponse<>(false, message, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.lincfusion.udyamlinc.common;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Every failure leaves the service in the same envelope the UI already parses. */
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> notFound(ResourceNotFoundException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.fail(e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(BadCredentialsException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> badCredentials(BadCredentialsException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.fail(e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> invalid(MethodArgumentNotValidException e) {
|
||||
String detail = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(f -> f.getField() + " " + f.getDefaultMessage())
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.badRequest().body(ApiResponse.fail(detail));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> illegal(IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.fail(e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> unexpected(Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.fail("Unexpected error: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.lincfusion.udyamlinc.common;
|
||||
|
||||
public class ResourceNotFoundException extends RuntimeException {
|
||||
|
||||
public ResourceNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.lincfusion.udyamlinc.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* The HTTP client used for external feeds, and the scheduler that drives them.
|
||||
*
|
||||
* <p>Short, explicit timeouts are the point: a government endpoint that hangs
|
||||
* must fail the nightly job quickly, not hold a thread until someone notices.
|
||||
* Nothing in the request path uses this client — see ARCHITECTURE §2.1.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class ExternalFeedConfig {
|
||||
|
||||
@Value("${udyam.ingestion.http.connect-timeout-ms:10000}")
|
||||
private int connectTimeoutMs;
|
||||
|
||||
@Value("${udyam.ingestion.http.read-timeout-ms:30000}")
|
||||
private int readTimeoutMs;
|
||||
|
||||
@Bean("externalFeedClient")
|
||||
public RestClient externalFeedClient(RestClient.Builder builder) {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(connectTimeoutMs);
|
||||
factory.setReadTimeout(readTimeoutMs);
|
||||
return builder.requestFactory(factory).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.lincfusion.udyamlinc.config;
|
||||
|
||||
import com.lincfusion.udyamlinc.security.JwtAuthFilter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/** Stateless JWT security. /auth/** is open; everything else needs a token. */
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
|
||||
@Value("${udyam.cors.allowed-origins}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/auth/**", "/health").permitAll()
|
||||
// The officer console and the enterprise app are separate surfaces.
|
||||
.requestMatchers("/officer/**", "/forecast/**").hasAuthority("SCOPE_OFFICER")
|
||||
.requestMatchers("/app/**").hasAuthority("SCOPE_ENTERPRISE")
|
||||
// Any officer may read the configuration; only a supervisor may change it.
|
||||
.requestMatchers(HttpMethod.GET, "/admin/**").hasAuthority("SCOPE_OFFICER")
|
||||
.requestMatchers("/admin/**").hasAuthority("ROLE_SUPERVISOR")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
cfg.setAllowedHeaders(List.of("*"));
|
||||
cfg.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.lincfusion.udyamlinc.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** Request bodies. Records, so the JSON shape is the type. */
|
||||
public final class UdyamRequests {
|
||||
|
||||
private UdyamRequests() {
|
||||
}
|
||||
|
||||
public record OfficerLoginRequest(@NotBlank String username, @NotBlank String password) {
|
||||
}
|
||||
|
||||
public record AppLoginRequest(@NotBlank String enterpriseId, @NotBlank String pin) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A ledger entry pushed from the phone. {@code clientId} was generated
|
||||
* offline; the server treats a repeat as an idempotent no-op.
|
||||
*/
|
||||
public record LedgerEntryRequest(
|
||||
@NotBlank String clientId,
|
||||
@NotBlank String kind,
|
||||
@Positive BigDecimal amount,
|
||||
@NotBlank String category,
|
||||
String note,
|
||||
String entryDate) {
|
||||
}
|
||||
|
||||
/** Officer disposition on an alert. */
|
||||
public record AlertActionRequest(@NotBlank String state, String note) {
|
||||
}
|
||||
|
||||
public record ScheduleVisitRequest(
|
||||
@NotBlank String enterpriseId,
|
||||
@NotBlank String actionCode,
|
||||
String dueDate) {
|
||||
}
|
||||
|
||||
public record VisitOutcomeRequest(@NotBlank String outcome, Boolean officerOverride) {
|
||||
}
|
||||
|
||||
public record ConsentRequest(boolean active) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.lincfusion.udyamlinc.dto;
|
||||
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.enterprise.LedgerEntry;
|
||||
import com.lincfusion.udyamlinc.officer.FieldVisit;
|
||||
import com.lincfusion.udyamlinc.risk.ForecastPoint;
|
||||
import com.lincfusion.udyamlinc.risk.ReasonCode;
|
||||
import com.lincfusion.udyamlinc.risk.RiskAlert;
|
||||
import com.lincfusion.udyamlinc.risk.StressScore;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** Response shapes. These mirror the interfaces in UdyamLinc-UI/src/lib/api.ts. */
|
||||
public final class UdyamResponses {
|
||||
|
||||
private UdyamResponses() {
|
||||
}
|
||||
|
||||
public record OfficerSessionResponse(
|
||||
String token,
|
||||
String tokenType,
|
||||
String username,
|
||||
String fullName,
|
||||
String role,
|
||||
List<String> blocks,
|
||||
String branch) {
|
||||
}
|
||||
|
||||
public record EnterpriseSessionResponse(
|
||||
String token,
|
||||
String tokenType,
|
||||
String enterpriseId,
|
||||
String name,
|
||||
String nameHi,
|
||||
String sector,
|
||||
String block,
|
||||
String lang) {
|
||||
}
|
||||
|
||||
/** An enterprise flattened with its sector code, as the console's table expects. */
|
||||
public record EnterpriseRow(
|
||||
Long id,
|
||||
String enterpriseId,
|
||||
String name,
|
||||
String nameHi,
|
||||
String sector,
|
||||
String block,
|
||||
String district,
|
||||
Integer ownerCount,
|
||||
Integer vintageMonths,
|
||||
BigDecimal loanOutstanding,
|
||||
BigDecimal monthlyInstalment,
|
||||
Integer score,
|
||||
String band,
|
||||
List<Integer> trend,
|
||||
String lastUpdated) {
|
||||
}
|
||||
|
||||
/** Dashboard KPI tiles plus the enterprises-by-risk register. */
|
||||
public record PortfolioResponse(
|
||||
String block,
|
||||
String month,
|
||||
long total,
|
||||
long totalDelta,
|
||||
long red,
|
||||
long redDelta,
|
||||
long watch,
|
||||
long watchDelta,
|
||||
long standard,
|
||||
long standardDelta,
|
||||
int updatedMinutesAgo,
|
||||
List<EnterpriseRow> rows) {
|
||||
}
|
||||
|
||||
/** Everything the risk detail panel needs for one enterprise. */
|
||||
public record EnterpriseDetailResponse(
|
||||
Enterprise enterprise,
|
||||
String sector,
|
||||
String district,
|
||||
StressScore score,
|
||||
List<ReasonCode> reasons,
|
||||
List<ForecastPoint> forecast,
|
||||
List<RiskAlert> alerts,
|
||||
List<FieldVisit> visits,
|
||||
List<LedgerEntry> ledger) {
|
||||
}
|
||||
|
||||
public record ForecastResponse(
|
||||
String enterpriseId,
|
||||
String modelVersion,
|
||||
String engine,
|
||||
int horizonMonths,
|
||||
String breachMonth,
|
||||
BigDecimal shortfall,
|
||||
List<ForecastPoint> points) {
|
||||
}
|
||||
|
||||
/** The enterprise app's Home tab in one call. */
|
||||
public record AppHomeResponse(
|
||||
Enterprise enterprise,
|
||||
RiskAlert alert,
|
||||
List<ForecastPoint> forecast,
|
||||
BigDecimal todayIn,
|
||||
BigDecimal todayOut,
|
||||
String adviceEn,
|
||||
String adviceHi,
|
||||
String lastSyncedAt) {
|
||||
}
|
||||
|
||||
public record SyncResultResponse(int accepted, int duplicates, String syncedAt) {
|
||||
}
|
||||
|
||||
/** One playbook action, offered for a band. */
|
||||
public record PlaybookActionResponse(
|
||||
String code, String label, String labelHi, String appliesTo, String detail) {
|
||||
}
|
||||
|
||||
/** Sector pack rollup for the Forecasts screen. */
|
||||
public record SectorRollupResponse(
|
||||
String sector, String label, long count, long red, long watch, long standard, int avgScore) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.lincfusion.udyamlinc.enterprise;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A monitored microenterprise.
|
||||
*
|
||||
* <p>No owner name, phone, Aadhaar or account number is held anywhere on this
|
||||
* record. The deck's promise that no personally identifiable data is stored is
|
||||
* enforced here by schema design rather than by policy control — there is no
|
||||
* column to put it in.
|
||||
*
|
||||
* <p>{@code score} and {@code band} are a denormalised cache written by the risk
|
||||
* engine after each scoring run, so list queries need one table. The system of
|
||||
* record is {@code stress_score}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "enterprise", indexes = {
|
||||
@Index(name = "ix_ent_enterprise_id", columnList = "enterpriseId", unique = true),
|
||||
@Index(name = "ix_ent_district", columnList = "districtId"),
|
||||
@Index(name = "ix_ent_sector", columnList = "sectorId"),
|
||||
@Index(name = "ix_ent_band", columnList = "band"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Enterprise {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** Opaque public identifier, e.g. MSME-4417. */
|
||||
@Column(nullable = false, unique = true, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
@Column(nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
@Column(length = 128)
|
||||
private String nameHi;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long sectorId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long districtId;
|
||||
|
||||
/** Sub-district administrative unit; the console's default scope filter. */
|
||||
@Column(nullable = false, length = 64)
|
||||
private String block;
|
||||
|
||||
/**
|
||||
* Enterprise location. Held at premises granularity because climate features
|
||||
* are gridded — this is a place, not a person.
|
||||
*/
|
||||
private Double latitude;
|
||||
|
||||
private Double longitude;
|
||||
|
||||
/** The mandi whose prices apply to this enterprise. */
|
||||
private Long marketId;
|
||||
|
||||
private Integer ownerCount;
|
||||
|
||||
/** Months on the institution's book. */
|
||||
private Integer vintageMonths;
|
||||
|
||||
/** Denormalised from loan_record for list queries. */
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal loanOutstanding;
|
||||
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal monthlyInstalment;
|
||||
|
||||
/** Latest Composite Stress Score — cache, see class note. */
|
||||
private Integer score;
|
||||
|
||||
/** RED | WATCH | STANDARD — cache, see class note. */
|
||||
@Column(length = 16)
|
||||
private String band;
|
||||
|
||||
/** Consent is revocable and purpose-bound; scoring stops when it is withdrawn. */
|
||||
private Boolean consentActive;
|
||||
|
||||
private LocalDateTime consentUpdatedAt;
|
||||
|
||||
private LocalDateTime lastScoredAt;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
if (consentActive == null) consentActive = Boolean.TRUE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.lincfusion.udyamlinc.enterprise;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface EnterpriseRepository extends JpaRepository<Enterprise, Long> {
|
||||
|
||||
Optional<Enterprise> findByEnterpriseId(String enterpriseId);
|
||||
|
||||
List<Enterprise> findByBlockOrderByScoreDesc(String block);
|
||||
|
||||
List<Enterprise> findByDistrictIdOrderByScoreDesc(Long districtId);
|
||||
|
||||
List<Enterprise> findBySectorIdOrderByScoreDesc(Long sectorId);
|
||||
|
||||
List<Enterprise> findAllByOrderByScoreDesc();
|
||||
|
||||
long countByBand(String band);
|
||||
|
||||
long countByBlockAndBand(String block, String band);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.lincfusion.udyamlinc.enterprise;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One three-tap ledger entry.
|
||||
*
|
||||
* <p>{@code clientId} is generated on the phone, so an entry made with no
|
||||
* connectivity has identity before the server ever sees it. Sync is idempotent
|
||||
* on that id, which is what makes draining a queue twice after a flaky
|
||||
* reconnect harmless.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "ledger_entry", indexes = {
|
||||
@Index(name = "ix_ledger_enterprise", columnList = "enterpriseId"),
|
||||
@Index(name = "ix_ledger_client_id", columnList = "clientId", unique = true),
|
||||
@Index(name = "ix_ledger_date", columnList = "enterpriseId,entryDate"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class LedgerEntry {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
private String clientId;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
/** IN | OUT | STOCK */
|
||||
@Column(nullable = false, length = 8)
|
||||
private String kind;
|
||||
|
||||
@Column(nullable = false, precision = 14, scale = 2)
|
||||
private BigDecimal amount;
|
||||
|
||||
@Column(nullable = false, length = 32)
|
||||
private String category;
|
||||
|
||||
@Column(length = 255)
|
||||
private String note;
|
||||
|
||||
/** The day the money moved, which may predate the sync by weeks. */
|
||||
@Column(nullable = false)
|
||||
private LocalDate entryDate;
|
||||
|
||||
/** When the entry reached the server. Null means it is still only on the phone. */
|
||||
private LocalDateTime syncedAt;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lincfusion.udyamlinc.enterprise;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface LedgerEntryRepository extends JpaRepository<LedgerEntry, Long> {
|
||||
|
||||
List<LedgerEntry> findByEnterpriseIdOrderByEntryDateDesc(String enterpriseId);
|
||||
|
||||
List<LedgerEntry> findByEnterpriseIdAndEntryDate(String enterpriseId, LocalDate entryDate);
|
||||
|
||||
List<LedgerEntry> findByEnterpriseIdAndEntryDateGreaterThanEqualOrderByEntryDateDesc(
|
||||
String enterpriseId, LocalDate from);
|
||||
|
||||
Optional<LedgerEntry> findByClientId(String clientId);
|
||||
|
||||
long countByEnterpriseId(String enterpriseId);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.lincfusion.udyamlinc.enterprise;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One loan on the institution's book, as the nightly CBS extract reports it.
|
||||
*
|
||||
* <p>{@code dpd} is the single most important column in the schema: today it
|
||||
* drives the {@code emi_delay} feature and the obligation-load sub-score, and its
|
||||
* history is the training label when the model arrives. No account number and no
|
||||
* borrower identity — the loan is attached to the opaque enterprise id.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "loan_record", indexes = {
|
||||
@Index(name = "ix_loan_enterprise", columnList = "enterpriseId"),
|
||||
@Index(name = "ix_loan_account_ref", columnList = "accountRef", unique = true),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class LoanRecord {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** Opaque loan reference from the lender's extract — not an account number. */
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
private String accountRef;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
@Column(length = 32)
|
||||
private String product;
|
||||
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal sanctionedAmount;
|
||||
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal outstandingAmount;
|
||||
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal instalmentAmount;
|
||||
|
||||
private LocalDate nextDueDate;
|
||||
|
||||
private LocalDate lastPaidDate;
|
||||
|
||||
/** Days past due at the extract date. */
|
||||
private Integer dpd;
|
||||
|
||||
/** 0 | 1-30 | 31-60 | 61-90 | 90+ */
|
||||
@Column(length = 16)
|
||||
private String bucket;
|
||||
|
||||
private Boolean closed;
|
||||
|
||||
/** The date the extract describes — features must not read past this. */
|
||||
@Column(nullable = false)
|
||||
private LocalDate asOfDate;
|
||||
|
||||
private LocalDateTime observedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (observedAt == null) observedAt = LocalDateTime.now();
|
||||
if (closed == null) closed = Boolean.FALSE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.lincfusion.udyamlinc.enterprise;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface LoanRecordRepository extends JpaRepository<LoanRecord, Long> {
|
||||
|
||||
List<LoanRecord> findByEnterpriseIdAndClosedFalse(String enterpriseId);
|
||||
|
||||
/** Point-in-time: only extracts describing a date at or before the scoring date. */
|
||||
List<LoanRecord> findByEnterpriseIdAndAsOfDateLessThanEqual(String enterpriseId, LocalDate asOfDate);
|
||||
|
||||
Optional<LoanRecord> findByAccountRef(String accountRef);
|
||||
|
||||
long countByEnterpriseId(String enterpriseId);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.lincfusion.udyamlinc.enterprise;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Consented weekly transaction aggregates — counts and totals, never individual
|
||||
* transactions, which is what keeps this inside the consent artefact's purpose.
|
||||
*
|
||||
* <p>The source is pluggable: an Account Aggregator flow once FIU registration is
|
||||
* through, a PSP settlement report, or manual entry during the pilot.
|
||||
* {@code consentRef} is stored so a revocation can find and delete its rows.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "upi_summary", indexes = {
|
||||
@Index(name = "ix_upi_key", columnList = "enterpriseId,weekStart", unique = true),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class UpiSummary {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate weekStart;
|
||||
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal inflowAmount;
|
||||
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal outflowAmount;
|
||||
|
||||
private Integer inflowCount;
|
||||
|
||||
private Integer outflowCount;
|
||||
|
||||
/** ACCOUNT_AGGREGATOR | PSP_REPORT | MANUAL */
|
||||
@Column(length = 32)
|
||||
private String source;
|
||||
|
||||
/** The consent artefact these rows were collected under. */
|
||||
@Column(length = 64)
|
||||
private String consentRef;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate validForDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime observedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (observedAt == null) observedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.lincfusion.udyamlinc.enterprise;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UpiSummaryRepository extends JpaRepository<UpiSummary, Long> {
|
||||
|
||||
Optional<UpiSummary> findByEnterpriseIdAndWeekStart(String enterpriseId, LocalDate weekStart);
|
||||
|
||||
@Query("""
|
||||
select u from UpiSummary u
|
||||
where u.enterpriseId = :enterpriseId
|
||||
and u.validForDate <= :asOfDate and u.observedAt <= :asOf
|
||||
order by u.weekStart desc
|
||||
""")
|
||||
List<UpiSummary> findTrailing(@Param("enterpriseId") String enterpriseId,
|
||||
@Param("asOfDate") LocalDate asOfDate,
|
||||
@Param("asOf") LocalDateTime asOf);
|
||||
|
||||
void deleteByEnterpriseId(String enterpriseId);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.lincfusion.udyamlinc.features;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The engineered indicators of ARCHITECTURE §4, as constants and as rows.
|
||||
*
|
||||
* <p>Raw prices are not features. An absolute maize price of ₹2,140 means
|
||||
* nothing; "feed cost 18 % above its eight-week mean while the egg rate is flat"
|
||||
* means something. Every derivation here turns a level into a change, a ratio or
|
||||
* an anomaly for exactly that reason.
|
||||
*
|
||||
* <p>Codes are snake_case because a trip-wire expression is written against them
|
||||
* directly: {@code emi_delay > 30 and cash_flow_change < -0.40}.
|
||||
*
|
||||
* <p>Three codes beyond §4's list are here because the seeded trip-wires already
|
||||
* name them — {@code dscr_p10}, {@code days_since_last_entry} and
|
||||
* {@code emi_to_inflow}. A rule referring to a feature nobody computes is a rule
|
||||
* that silently never fires, which is worse than no rule at all. They also give
|
||||
* the OBLIGATION_LOAD family the inputs it otherwise lacked.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FeatureCatalog {
|
||||
|
||||
// ── codes ───────────────────────────────────────────────
|
||||
|
||||
public static final String FEED_COST_CHANGE = "feed_cost_change";
|
||||
public static final String OUTPUT_PRICE_CHANGE = "output_price_change";
|
||||
public static final String MARGIN_SQUEEZE = "margin_squeeze";
|
||||
public static final String PRICE_Z_SCORE = "price_z_score";
|
||||
public static final String RAINFALL_ANOMALY = "rainfall_anomaly";
|
||||
public static final String HEAT_ANOMALY = "heat_anomaly";
|
||||
public static final String CASH_FLOW_CHANGE = "cash_flow_change";
|
||||
public static final String EMI_DELAY = "emi_delay";
|
||||
public static final String INVENTORY_DAYS = "inventory_days";
|
||||
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";
|
||||
public static final String DAYS_SINCE_LAST_ENTRY = "days_since_last_entry";
|
||||
public static final String EMI_TO_INFLOW = "emi_to_inflow";
|
||||
|
||||
public static final List<String> ALL = List.of(
|
||||
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);
|
||||
|
||||
// ── families ────────────────────────────────────────────
|
||||
|
||||
public static final String LIQUIDITY = "LIQUIDITY";
|
||||
public static final String BEHAVIOURAL = "BEHAVIOURAL";
|
||||
public static final String TREND_VOLATILITY = "TREND_VOLATILITY";
|
||||
public static final String OBLIGATION_LOAD = "OBLIGATION_LOAD";
|
||||
public static final String EXTERNAL_SHOCK = "EXTERNAL_SHOCK";
|
||||
public static final String QUALITY = "QUALITY";
|
||||
|
||||
// ── missing reasons ─────────────────────────────────────
|
||||
|
||||
public static final String NO_SIGNAL = "NO_SIGNAL";
|
||||
public static final String NO_LEDGER = "NO_LEDGER";
|
||||
public static final String NO_CBS = "NO_CBS";
|
||||
public static final String NO_CONSENT = "NO_CONSENT";
|
||||
public static final String NO_FORECAST = "NO_FORECAST";
|
||||
public static final String INSUFFICIENT_HISTORY = "INSUFFICIENT_HISTORY";
|
||||
|
||||
private final FeatureDefinitionRepository repository;
|
||||
|
||||
/** Idempotent: writes any catalogue row that is not already present. */
|
||||
public void seed() {
|
||||
List<FeatureDefinition> catalogue = List.of(
|
||||
def(FEED_COST_CHANGE, "Feed cost change", EXTERNAL_SHOCK, "RATIO", "AGMARKNET", 10,
|
||||
"HIGHER_IS_WORSE", "Input-side modal price vs its 8-week trailing mean, weighted across the sector's INPUT commodities"),
|
||||
def(OUTPUT_PRICE_CHANGE, "Output price change", EXTERNAL_SHOCK, "RATIO", "AGMARKNET", 10,
|
||||
"LOWER_IS_WORSE", "Same derivation on the sector's OUTPUT commodities"),
|
||||
def(MARGIN_SQUEEZE, "Margin squeeze", EXTERNAL_SHOCK, "RATIO", "AGMARKNET", 10,
|
||||
"HIGHER_IS_WORSE", "feed_cost_change − output_price_change; a feed rise matched by an output rise is not a squeeze"),
|
||||
def(PRICE_Z_SCORE, "Seasonal price z-score", TREND_VOLATILITY, "INDEX", "AGMARKNET", 10,
|
||||
"HIGHER_IS_WORSE", "(current − seasonal mean for this ISO week ±2) / stdev, over all held history"),
|
||||
def(RAINFALL_ANOMALY, "Rainfall anomaly", EXTERNAL_SHOCK, "PCT", "CLIMATE", 14,
|
||||
"LOWER_IS_WORSE", "Season-to-date rainfall over the trailing window vs the 30-year normal for the same weeks"),
|
||||
def(HEAT_ANOMALY, "Heat anomaly", EXTERNAL_SHOCK, "INDEX", "CLIMATE", 14,
|
||||
"HIGHER_IS_WORSE", "Mean weekly max temperature minus the 30-year normal, over the trailing window"),
|
||||
def(CASH_FLOW_CHANGE, "Cash flow change", LIQUIDITY, "RATIO", "LEDGER", 7,
|
||||
"LOWER_IS_WORSE", "Ledger net position over the recent 4 weeks vs the prior 8-week mean"),
|
||||
def(EMI_DELAY, "EMI delay", BEHAVIOURAL, "DAYS", "CBS", 35,
|
||||
"HIGHER_IS_WORSE", "Worst DPD across open loans at the extract date"),
|
||||
def(INVENTORY_DAYS, "Inventory days", LIQUIDITY, "DAYS", "LEDGER", 14,
|
||||
"HIGHER_IS_WORSE", "Stock on hand divided by daily sales velocity over the recent window"),
|
||||
def(UPI_INFLOW_CHANGE, "UPI inflow change", LIQUIDITY, "RATIO", "UPI", 21,
|
||||
"LOWER_IS_WORSE", "Trailing 8-week consented inflow vs the 8 weeks before it"),
|
||||
def(FEED_STALENESS_DAYS, "Feed staleness", QUALITY, "DAYS", "AGMARKNET", null,
|
||||
"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(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,
|
||||
"HIGHER_IS_WORSE", "Monthly instalment across open loans divided by the monthly ledger inflow rate"));
|
||||
|
||||
int written = 0;
|
||||
for (FeatureDefinition candidate : catalogue) {
|
||||
if (repository.findByCode(candidate.getCode()).isEmpty()) {
|
||||
repository.save(candidate);
|
||||
written++;
|
||||
}
|
||||
}
|
||||
if (written > 0) log.info("Seeded {} feature definitions", written);
|
||||
}
|
||||
|
||||
private FeatureDefinition def(String code, String label, String family, String unit, String sourceKind,
|
||||
Integer staleness, String direction, String derivation) {
|
||||
return FeatureDefinition.builder()
|
||||
.code(code).label(label).family(family).unit(unit).sourceKind(sourceKind)
|
||||
.stalenessToleranceDays(staleness).direction(direction).derivation(derivation)
|
||||
.active(true).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.lincfusion.udyamlinc.features;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ApiResponse;
|
||||
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Phase C terminates in a table you can inspect; this is how you inspect it.
|
||||
*
|
||||
* <p>Under {@code /admin}, so reads are open to any officer and the recompute
|
||||
* trigger is supervisor-only under the existing rule.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/features")
|
||||
@RequiredArgsConstructor
|
||||
public class FeatureController {
|
||||
|
||||
private final FeatureDefinitionRepository definitionRepository;
|
||||
private final FeatureValueRepository valueRepository;
|
||||
private final FeatureSnapshotRepository snapshotRepository;
|
||||
private final FeatureEngineeringService engineeringService;
|
||||
private final EnterpriseRepository enterpriseRepository;
|
||||
|
||||
@GetMapping("/definitions")
|
||||
public ApiResponse<List<FeatureDefinition>> definitions() {
|
||||
return ApiResponse.ok(definitionRepository.findByActiveTrue());
|
||||
}
|
||||
|
||||
/** The wide row the engine reads — latest, or as it stood on a given date. */
|
||||
@GetMapping("/snapshot/{enterpriseId}")
|
||||
public ApiResponse<FeatureSnapshot> snapshot(
|
||||
@PathVariable String enterpriseId,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
|
||||
Optional<FeatureSnapshot> snapshot = date == null
|
||||
? snapshotRepository.findFirstByEnterpriseIdOrderByAsOfDateDesc(enterpriseId)
|
||||
: snapshotRepository.findByEnterpriseIdAndAsOfDate(enterpriseId, date);
|
||||
|
||||
return ApiResponse.ok(snapshot.orElseThrow(() ->
|
||||
new ResourceNotFoundException("No feature snapshot for " + enterpriseId
|
||||
+ (date == null ? "" : " at " + date))));
|
||||
}
|
||||
|
||||
/** The tall rows behind it, including the nulls and why they are null. */
|
||||
@GetMapping("/values/{enterpriseId}")
|
||||
public ApiResponse<List<FeatureValue>> values(
|
||||
@PathVariable String enterpriseId,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
|
||||
LocalDate asOf = date != null ? date
|
||||
: snapshotRepository.findFirstByEnterpriseIdOrderByAsOfDateDesc(enterpriseId)
|
||||
.map(FeatureSnapshot::getAsOfDate)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No features computed for " + enterpriseId));
|
||||
|
||||
return ApiResponse.ok(valueRepository.findByEnterpriseIdAndValidForDateOrderByFeatureCode(enterpriseId, asOf));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the catalogue is actually populated across the book. A feature
|
||||
* at 4 % coverage is not a feature — it is a column, and the engine should be
|
||||
* told to stop weighting it.
|
||||
*/
|
||||
@GetMapping("/coverage")
|
||||
public ApiResponse<Map<String, Object>> coverage(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
|
||||
LocalDate asOf = date == null ? LocalDate.now() : date;
|
||||
List<FeatureSnapshot> snapshots = snapshotRepository.findByAsOfDate(asOf);
|
||||
|
||||
Map<String, Integer> present = new LinkedHashMap<>();
|
||||
FeatureCatalog.ALL.forEach(code -> present.put(code, 0));
|
||||
for (FeatureSnapshot snapshot : snapshots) {
|
||||
snapshot.asFeatureMap().forEach((code, value) -> {
|
||||
if (value != null) present.merge(code, 1, Integer::sum);
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("asOfDate", asOf);
|
||||
body.put("snapshots", snapshots.size());
|
||||
Map<String, Object> byFeature = new LinkedHashMap<>();
|
||||
present.forEach((code, count) -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("populated", count);
|
||||
row.put("coveragePct", snapshots.isEmpty() ? 0
|
||||
: Math.round(count * 1000.0 / snapshots.size()) / 10.0);
|
||||
byFeature.put(code, row);
|
||||
});
|
||||
body.put("features", byFeature);
|
||||
return ApiResponse.ok(body);
|
||||
}
|
||||
|
||||
// ── trigger ─────────────────────────────────────────────
|
||||
|
||||
/** Recompute the whole book. Supervisor-only under the {@code /admin/**} write rule. */
|
||||
@PostMapping("/compute")
|
||||
public ApiResponse<FeatureEngineeringService.Summary> compute(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
return ApiResponse.ok("Features computed",
|
||||
engineeringService.computeAll(date == null ? LocalDate.now() : date, null));
|
||||
}
|
||||
|
||||
/** One enterprise, for when a single reason code needs explaining. */
|
||||
@PostMapping("/compute/{enterpriseId}")
|
||||
public ApiResponse<FeatureSnapshot> computeOne(
|
||||
@PathVariable String enterpriseId,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
|
||||
Enterprise enterprise = enterpriseRepository.findByEnterpriseId(enterpriseId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No enterprise " + enterpriseId));
|
||||
return ApiResponse.ok("Features computed", engineeringService.computeFor(
|
||||
enterprise, date == null ? LocalDate.now() : date, java.time.LocalDateTime.now(), null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.lincfusion.udyamlinc.features;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* The feature catalogue. A row here is the contract for one engineered
|
||||
* indicator: what it means, what unit it carries, and how old it is allowed to
|
||||
* be before anything downstream may act on it.
|
||||
*
|
||||
* <p>{@code stalenessToleranceDays} is the column trip-wires read. A rule firing
|
||||
* on a three-week-old price is worse than no rule, and the tolerance is per
|
||||
* feature because a mandi price goes stale in days while a CBS extract is
|
||||
* monthly by design.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "feature_definition", indexes = @Index(name = "ix_feature_def_code", columnList = "code", unique = true))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class FeatureDefinition {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** snake_case, matching the name a trip-wire expression uses. */
|
||||
@Column(nullable = false, unique = true, length = 48)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 96)
|
||||
private String label;
|
||||
|
||||
/** LIQUIDITY | BEHAVIOURAL | TREND_VOLATILITY | OBLIGATION_LOAD | EXTERNAL_SHOCK | QUALITY */
|
||||
@Column(nullable = false, length = 32)
|
||||
private String family;
|
||||
|
||||
/** RATIO | PCT | DAYS | INDEX */
|
||||
@Column(length = 16)
|
||||
private String unit;
|
||||
|
||||
/** Which upstream table it derives from — AGMARKNET, CLIMATE, LEDGER, CBS, UPI. */
|
||||
@Column(length = 32)
|
||||
private String sourceKind;
|
||||
|
||||
/** Beyond this age the value must not be acted on. Null means it never goes stale. */
|
||||
private Integer stalenessToleranceDays;
|
||||
|
||||
/** HIGHER_IS_WORSE | LOWER_IS_WORSE */
|
||||
@Column(length = 24)
|
||||
private String direction;
|
||||
|
||||
@Column(length = 512)
|
||||
private String derivation;
|
||||
|
||||
private Boolean active;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
void stamp() {
|
||||
if (active == null) active = Boolean.TRUE;
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lincfusion.udyamlinc.features;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FeatureDefinitionRepository extends JpaRepository<FeatureDefinition, Long> {
|
||||
|
||||
Optional<FeatureDefinition> findByCode(String code);
|
||||
|
||||
List<FeatureDefinition> findByActiveTrue();
|
||||
|
||||
List<FeatureDefinition> findByFamilyAndActiveTrue(String family);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
package com.lincfusion.udyamlinc.features;
|
||||
|
||||
import com.lincfusion.udyamlinc.enterprise.*;
|
||||
import com.lincfusion.udyamlinc.ingestion.*;
|
||||
import com.lincfusion.udyamlinc.master.SectorCommodity;
|
||||
import com.lincfusion.udyamlinc.master.SectorCommodityRepository;
|
||||
import com.lincfusion.udyamlinc.risk.ForecastPoint;
|
||||
import com.lincfusion.udyamlinc.risk.ForecastPointRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Turns signals into indicators. The only writer of the feature store, and the
|
||||
* only path by which anything reaches the model — ingestion writes signals, this
|
||||
* writes features, the AI layer consumes features and nothing else. That is what
|
||||
* lets the model be retrained or replaced without touching ingestion or the API.
|
||||
*
|
||||
* <p>Every read here is point-in-time: each repository query filters on both
|
||||
* {@code validForDate <= asOfDate} and {@code observedAt <= observedAsOf}, so
|
||||
* recomputing last month's snapshot uses only what was actually knowable last
|
||||
* month. Without that, a backtest looks excellent and production does not
|
||||
* reproduce it.
|
||||
*
|
||||
* <p>A feature that cannot be computed is written as null with a reason, not
|
||||
* skipped. "We did not know" and "we never looked" are different facts, and the
|
||||
* forecast widens honestly only if the first one is recorded.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FeatureEngineeringService {
|
||||
|
||||
private final EnterpriseRepository enterpriseRepository;
|
||||
private final SectorCommodityRepository sectorCommodityRepository;
|
||||
private final MarketSignalRepository marketSignalRepository;
|
||||
private final ClimateSignalRepository climateSignalRepository;
|
||||
private final LedgerEntryRepository ledgerRepository;
|
||||
private final LoanRecordRepository loanRepository;
|
||||
private final UpiSummaryRepository upiRepository;
|
||||
private final ForecastPointRepository forecastPointRepository;
|
||||
private final FeatureValueRepository valueRepository;
|
||||
private final FeatureSnapshotRepository snapshotRepository;
|
||||
private final FeatureCatalog catalog;
|
||||
|
||||
/** Weeks of price history averaged for a "vs trailing mean" change. */
|
||||
@Value("${udyam.features.price-window-weeks:8}")
|
||||
private int priceWindowWeeks;
|
||||
|
||||
/** Recent window for ledger and climate aggregates. */
|
||||
@Value("${udyam.features.recent-window-weeks:4}")
|
||||
private int recentWindowWeeks;
|
||||
|
||||
@Value("${udyam.features.climate-window-weeks:8}")
|
||||
private int climateWindowWeeks;
|
||||
|
||||
/** Minimum observations before a z-score is meaningful rather than noise. */
|
||||
@Value("${udyam.features.min-zscore-samples:6}")
|
||||
private int minZScoreSamples;
|
||||
|
||||
// ── entry points ────────────────────────────────────────
|
||||
|
||||
/** One pass over the whole book. Consent-revoked enterprises are skipped, not computed. */
|
||||
public Summary computeAll(LocalDate asOfDate, Long scoringRunId) {
|
||||
catalog.seed();
|
||||
LocalDateTime observedAsOf = LocalDateTime.now();
|
||||
int computed = 0, skipped = 0, missing = 0;
|
||||
|
||||
for (Enterprise enterprise : enterpriseRepository.findAll()) {
|
||||
if (Boolean.FALSE.equals(enterprise.getConsentActive())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
FeatureSnapshot snapshot = computeFor(enterprise, asOfDate, observedAsOf, scoringRunId);
|
||||
computed++;
|
||||
missing += snapshot.getMissingCount() == null ? 0 : snapshot.getMissingCount();
|
||||
}
|
||||
|
||||
String message = "%d snapshots at %s · %d skipped for consent · %d null feature values"
|
||||
.formatted(computed, asOfDate, skipped, missing);
|
||||
log.info("Feature engineering — {}", message);
|
||||
return new Summary(computed, skipped, missing, message);
|
||||
}
|
||||
|
||||
/** The eleven catalogue features for one enterprise, tall rows and a wide snapshot. */
|
||||
public FeatureSnapshot computeFor(Enterprise enterprise, LocalDate asOfDate,
|
||||
LocalDateTime observedAsOf, Long scoringRunId) {
|
||||
|
||||
Map<String, Computed> features = new LinkedHashMap<>();
|
||||
|
||||
List<SectorCommodity> inputs = sectorCommodityRepository.findBySectorIdAndRole(enterprise.getSectorId(), "INPUT");
|
||||
List<SectorCommodity> outputs = sectorCommodityRepository.findBySectorIdAndRole(enterprise.getSectorId(), "OUTPUT");
|
||||
|
||||
Integer marketStaleness = marketStaleness(enterprise.getDistrictId(), asOfDate);
|
||||
|
||||
Computed feedCost = weightedPriceChange(enterprise.getDistrictId(), inputs, asOfDate, observedAsOf, marketStaleness);
|
||||
Computed outputPrice = weightedPriceChange(enterprise.getDistrictId(), outputs, asOfDate, observedAsOf, marketStaleness);
|
||||
|
||||
features.put(FeatureCatalog.FEED_COST_CHANGE, feedCost);
|
||||
features.put(FeatureCatalog.OUTPUT_PRICE_CHANGE, outputPrice);
|
||||
features.put(FeatureCatalog.MARGIN_SQUEEZE, marginSqueeze(feedCost, outputPrice, marketStaleness));
|
||||
features.put(FeatureCatalog.PRICE_Z_SCORE,
|
||||
priceZScore(enterprise.getDistrictId(), outputs, asOfDate, observedAsOf, marketStaleness));
|
||||
features.put(FeatureCatalog.RAINFALL_ANOMALY, rainfallAnomaly(enterprise.getDistrictId(), asOfDate, observedAsOf));
|
||||
features.put(FeatureCatalog.HEAT_ANOMALY, heatAnomaly(enterprise.getDistrictId(), asOfDate, observedAsOf));
|
||||
features.put(FeatureCatalog.CASH_FLOW_CHANGE, cashFlowChange(enterprise.getEnterpriseId(), asOfDate));
|
||||
features.put(FeatureCatalog.EMI_DELAY, emiDelay(enterprise.getEnterpriseId(), asOfDate));
|
||||
features.put(FeatureCatalog.INVENTORY_DAYS, inventoryDays(enterprise.getEnterpriseId(), asOfDate));
|
||||
features.put(FeatureCatalog.UPI_INFLOW_CHANGE, upiInflowChange(enterprise.getEnterpriseId(), asOfDate, observedAsOf));
|
||||
features.put(FeatureCatalog.FEED_STALENESS_DAYS, marketStaleness == null
|
||||
? Computed.missing(FeatureCatalog.NO_SIGNAL)
|
||||
: Computed.of(BigDecimal.valueOf(marketStaleness), marketStaleness));
|
||||
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));
|
||||
|
||||
return persist(enterprise.getEnterpriseId(), asOfDate, observedAsOf, scoringRunId, features);
|
||||
}
|
||||
|
||||
// ── price derivations ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* A level is not a feature. This is the modal price against its trailing mean,
|
||||
* averaged across the sector's commodities for that role and weighted by how
|
||||
* much of the trade each one actually is.
|
||||
*/
|
||||
private Computed weightedPriceChange(Long districtId, List<SectorCommodity> commodities,
|
||||
LocalDate asOfDate, LocalDateTime observedAsOf, Integer staleness) {
|
||||
if (commodities.isEmpty()) return Computed.missing(FeatureCatalog.NO_SIGNAL);
|
||||
|
||||
BigDecimal weightedSum = BigDecimal.ZERO;
|
||||
int totalWeight = 0;
|
||||
boolean anyHistory = false;
|
||||
|
||||
for (SectorCommodity link : commodities) {
|
||||
List<MarketSignal> series = marketSignalRepository.findTrailing(
|
||||
districtId, link.getCommodityId(), asOfDate, observedAsOf);
|
||||
BigDecimal change = changeVsTrailingMean(series);
|
||||
if (change == null) continue;
|
||||
anyHistory = true;
|
||||
int weight = link.getWeight() == null || link.getWeight() <= 0 ? 1 : link.getWeight();
|
||||
weightedSum = weightedSum.add(change.multiply(BigDecimal.valueOf(weight)));
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
if (!anyHistory || totalWeight == 0) {
|
||||
return Computed.missing(FeatureCatalog.NO_SIGNAL);
|
||||
}
|
||||
return Computed.of(weightedSum.divide(BigDecimal.valueOf(totalWeight), 4, RoundingMode.HALF_UP), staleness);
|
||||
}
|
||||
|
||||
/** (latest − mean of the prior window) / mean. Null until there is enough history to mean anything. */
|
||||
private BigDecimal changeVsTrailingMean(List<MarketSignal> descendingByWeek) {
|
||||
List<BigDecimal> modal = descendingByWeek.stream()
|
||||
.map(MarketSignal::getModalPrice)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
if (modal.size() < 4) return null;
|
||||
|
||||
BigDecimal latest = modal.get(0);
|
||||
List<BigDecimal> prior = modal.subList(1, Math.min(modal.size(), priceWindowWeeks + 1));
|
||||
BigDecimal mean = mean(prior);
|
||||
if (mean == null || mean.signum() == 0) return null;
|
||||
return latest.subtract(mean).divide(mean, 4, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** A feed rise matched by an output rise is not a squeeze; the difference is. */
|
||||
private Computed marginSqueeze(Computed feedCost, Computed outputPrice, Integer staleness) {
|
||||
if (feedCost.value() == null || outputPrice.value() == null) {
|
||||
return Computed.missing(FeatureCatalog.NO_SIGNAL);
|
||||
}
|
||||
return Computed.of(feedCost.value().subtract(outputPrice.value()), staleness);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seasonal z-score on the sale-side commodity: this week against the same week
|
||||
* of the year across all held history, ±2 weeks for sample size. Prices are
|
||||
* seasonal, so comparing March with the annual mean would call every harvest a
|
||||
* shock.
|
||||
*/
|
||||
private Computed priceZScore(Long districtId, List<SectorCommodity> outputs,
|
||||
LocalDate asOfDate, LocalDateTime observedAsOf, Integer staleness) {
|
||||
if (outputs.isEmpty()) return Computed.missing(FeatureCatalog.NO_SIGNAL);
|
||||
|
||||
SectorCommodity primary = outputs.stream()
|
||||
.max(Comparator.comparing(sc -> sc.getWeight() == null ? 0 : sc.getWeight()))
|
||||
.orElse(outputs.get(0));
|
||||
|
||||
List<MarketSignal> series = marketSignalRepository.findTrailing(
|
||||
districtId, primary.getCommodityId(), asOfDate, observedAsOf);
|
||||
if (series.isEmpty()) return Computed.missing(FeatureCatalog.NO_SIGNAL);
|
||||
|
||||
MarketSignal latest = series.get(0);
|
||||
if (latest.getModalPrice() == null) return Computed.missing(FeatureCatalog.NO_SIGNAL);
|
||||
int week = Weeks.isoWeek(latest.getWeekStart());
|
||||
|
||||
List<BigDecimal> seasonal = series.stream()
|
||||
.skip(1)
|
||||
.filter(s -> s.getModalPrice() != null)
|
||||
.filter(s -> weekDistance(Weeks.isoWeek(s.getWeekStart()), week) <= 2)
|
||||
.map(MarketSignal::getModalPrice)
|
||||
.toList();
|
||||
|
||||
if (seasonal.size() < minZScoreSamples) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
BigDecimal mean = mean(seasonal);
|
||||
BigDecimal stdev = stdev(seasonal, mean);
|
||||
if (stdev == null || stdev.signum() == 0) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
return Computed.of(latest.getModalPrice().subtract(mean).divide(stdev, 4, RoundingMode.HALF_UP), staleness);
|
||||
}
|
||||
|
||||
/** ISO weeks wrap, so week 52 and week 1 are two apart, not fifty-one. */
|
||||
private int weekDistance(int a, int b) {
|
||||
int raw = Math.abs(a - b);
|
||||
return Math.min(raw, 53 - raw);
|
||||
}
|
||||
|
||||
// ── climate derivations ─────────────────────────────────
|
||||
|
||||
private Computed rainfallAnomaly(Long districtId, LocalDate asOfDate, LocalDateTime observedAsOf) {
|
||||
List<ClimateSignal> series = climateSignalRepository.findTrailing(districtId, asOfDate, observedAsOf);
|
||||
if (series.isEmpty()) return Computed.missing(FeatureCatalog.NO_SIGNAL);
|
||||
|
||||
List<ClimateSignal> window = series.subList(0, Math.min(series.size(), climateWindowWeeks));
|
||||
Integer staleness = staleness(window.get(0).getValidForDate(), asOfDate);
|
||||
|
||||
BigDecimal observed = BigDecimal.ZERO, normal = BigDecimal.ZERO;
|
||||
boolean anyNormal = false;
|
||||
for (ClimateSignal week : window) {
|
||||
if (week.getRainfallMm() != null) observed = observed.add(week.getRainfallMm());
|
||||
if (week.getRainfallNormalMm() != null) {
|
||||
normal = normal.add(week.getRainfallNormalMm());
|
||||
anyNormal = true;
|
||||
}
|
||||
}
|
||||
if (!anyNormal || normal.signum() == 0) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
BigDecimal pct = observed.subtract(normal)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.divide(normal, 4, RoundingMode.HALF_UP);
|
||||
return Computed.of(pct, staleness);
|
||||
}
|
||||
|
||||
private Computed heatAnomaly(Long districtId, LocalDate asOfDate, LocalDateTime observedAsOf) {
|
||||
List<ClimateSignal> series = climateSignalRepository.findTrailing(districtId, asOfDate, observedAsOf);
|
||||
if (series.isEmpty()) return Computed.missing(FeatureCatalog.NO_SIGNAL);
|
||||
|
||||
List<ClimateSignal> window = series.subList(0, Math.min(series.size(), climateWindowWeeks));
|
||||
Integer staleness = staleness(window.get(0).getValidForDate(), asOfDate);
|
||||
|
||||
List<BigDecimal> anomalies = window.stream()
|
||||
.map(ClimateSignal::getHeatAnomalyC)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
if (anomalies.isEmpty()) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
return Computed.of(mean(anomalies), staleness);
|
||||
}
|
||||
|
||||
// ── ledger derivations ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Net position over the recent weeks against the mean of the weeks before it.
|
||||
* Entries are filtered on {@code entryDate}, not sync time — an entry made
|
||||
* offline three weeks ago describes three weeks ago.
|
||||
*/
|
||||
private Computed cashFlowChange(String enterpriseId, LocalDate asOfDate) {
|
||||
List<LedgerEntry> entries = ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream()
|
||||
.filter(e -> !e.getEntryDate().isAfter(asOfDate))
|
||||
.toList();
|
||||
if (entries.isEmpty()) return Computed.missing(FeatureCatalog.NO_LEDGER);
|
||||
|
||||
Integer staleness = staleness(entries.get(0).getEntryDate(), asOfDate);
|
||||
LocalDate recentFrom = asOfDate.minusWeeks(recentWindowWeeks);
|
||||
LocalDate priorFrom = recentFrom.minusWeeks(priceWindowWeeks);
|
||||
|
||||
BigDecimal recent = net(entries, recentFrom, asOfDate);
|
||||
BigDecimal prior = net(entries, priorFrom, recentFrom);
|
||||
if (prior.signum() == 0) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
// Both windows normalised to a weekly rate before comparison.
|
||||
BigDecimal recentRate = recent.divide(BigDecimal.valueOf(recentWindowWeeks), 4, RoundingMode.HALF_UP);
|
||||
BigDecimal priorRate = prior.divide(BigDecimal.valueOf(priceWindowWeeks), 4, RoundingMode.HALF_UP);
|
||||
if (priorRate.signum() == 0) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
BigDecimal change = recentRate.subtract(priorRate).divide(priorRate.abs(), 4, RoundingMode.HALF_UP);
|
||||
return Computed.of(change, staleness);
|
||||
}
|
||||
|
||||
private BigDecimal net(List<LedgerEntry> entries, LocalDate from, LocalDate to) {
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
for (LedgerEntry e : entries) {
|
||||
if (e.getEntryDate().isBefore(from) || !e.getEntryDate().isBefore(to)) continue;
|
||||
if ("IN".equals(e.getKind())) sum = sum.add(e.getAmount());
|
||||
else if ("OUT".equals(e.getKind())) sum = sum.subtract(e.getAmount());
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/** Stock on hand against sales velocity. Rising inventory days is cash turning into unsold goods. */
|
||||
private Computed inventoryDays(String enterpriseId, LocalDate asOfDate) {
|
||||
LocalDate from = asOfDate.minusWeeks(recentWindowWeeks);
|
||||
List<LedgerEntry> entries = ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream()
|
||||
.filter(e -> !e.getEntryDate().isAfter(asOfDate) && !e.getEntryDate().isBefore(from))
|
||||
.toList();
|
||||
if (entries.isEmpty()) return Computed.missing(FeatureCatalog.NO_LEDGER);
|
||||
|
||||
Integer staleness = staleness(entries.get(0).getEntryDate(), asOfDate);
|
||||
BigDecimal stock = BigDecimal.ZERO, sales = BigDecimal.ZERO;
|
||||
for (LedgerEntry e : entries) {
|
||||
if ("STOCK".equals(e.getKind())) stock = stock.add(e.getAmount());
|
||||
else if ("IN".equals(e.getKind())) sales = sales.add(e.getAmount());
|
||||
}
|
||||
if (sales.signum() == 0) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
long days = ChronoUnit.DAYS.between(from, asOfDate);
|
||||
BigDecimal dailySales = sales.divide(BigDecimal.valueOf(Math.max(days, 1)), 4, RoundingMode.HALF_UP);
|
||||
if (dailySales.signum() == 0) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
return Computed.of(stock.divide(dailySales, 4, RoundingMode.HALF_UP), staleness);
|
||||
}
|
||||
|
||||
/** Silence is a signal. An enterprise that stops recording has usually stopped trading normally. */
|
||||
private Computed daysSinceLastEntry(String enterpriseId, LocalDate asOfDate) {
|
||||
Optional<LocalDate> newest = ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream()
|
||||
.map(LedgerEntry::getEntryDate)
|
||||
.filter(date -> !date.isAfter(asOfDate))
|
||||
.max(LocalDate::compareTo);
|
||||
return newest
|
||||
.map(date -> {
|
||||
int days = staleness(date, asOfDate);
|
||||
return Computed.of(BigDecimal.valueOf(days), days);
|
||||
})
|
||||
.orElseGet(() -> Computed.missing(FeatureCatalog.NO_LEDGER));
|
||||
}
|
||||
|
||||
// ── institutional derivations ───────────────────────────
|
||||
|
||||
private Computed emiDelay(String enterpriseId, LocalDate asOfDate) {
|
||||
List<LoanRecord> loans = loanRepository
|
||||
.findByEnterpriseIdAndAsOfDateLessThanEqual(enterpriseId, asOfDate).stream()
|
||||
.filter(l -> !Boolean.TRUE.equals(l.getClosed()))
|
||||
.toList();
|
||||
if (loans.isEmpty()) return Computed.missing(FeatureCatalog.NO_CBS);
|
||||
|
||||
LocalDate newest = loans.stream().map(LoanRecord::getAsOfDate).max(LocalDate::compareTo).orElse(asOfDate);
|
||||
int worstDpd = loans.stream().map(LoanRecord::getDpd).filter(Objects::nonNull)
|
||||
.mapToInt(Integer::intValue).max().orElse(0);
|
||||
return Computed.of(BigDecimal.valueOf(worstDpd), staleness(newest, asOfDate));
|
||||
}
|
||||
|
||||
private Computed upiInflowChange(String enterpriseId, LocalDate asOfDate, LocalDateTime observedAsOf) {
|
||||
List<UpiSummary> series = upiRepository.findTrailing(enterpriseId, asOfDate, observedAsOf);
|
||||
if (series.isEmpty()) return Computed.missing(FeatureCatalog.NO_CONSENT);
|
||||
|
||||
Integer staleness = staleness(series.get(0).getValidForDate(), asOfDate);
|
||||
int window = priceWindowWeeks;
|
||||
if (series.size() < window * 2) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
BigDecimal recent = sumInflow(series.subList(0, window));
|
||||
BigDecimal prior = sumInflow(series.subList(window, Math.min(series.size(), window * 2)));
|
||||
if (prior.signum() == 0) return Computed.missing(FeatureCatalog.INSUFFICIENT_HISTORY);
|
||||
|
||||
return Computed.of(recent.subtract(prior).divide(prior, 4, RoundingMode.HALF_UP), staleness);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the instalment takes out of what comes in. Inflow is the ledger's own
|
||||
* money-in over the recent window, normalised to a month — the enterprise's
|
||||
* record of its trade, not an institutional estimate of it.
|
||||
*/
|
||||
private Computed emiToInflow(String enterpriseId, LocalDate asOfDate) {
|
||||
List<LoanRecord> loans = loanRepository
|
||||
.findByEnterpriseIdAndAsOfDateLessThanEqual(enterpriseId, asOfDate).stream()
|
||||
.filter(l -> !Boolean.TRUE.equals(l.getClosed()))
|
||||
.toList();
|
||||
if (loans.isEmpty()) return Computed.missing(FeatureCatalog.NO_CBS);
|
||||
|
||||
BigDecimal instalment = loans.stream()
|
||||
.map(LoanRecord::getInstalmentAmount)
|
||||
.filter(Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
if (instalment.signum() == 0) return Computed.missing(FeatureCatalog.NO_CBS);
|
||||
|
||||
LocalDate from = asOfDate.minusWeeks(recentWindowWeeks);
|
||||
BigDecimal inflow = ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream()
|
||||
.filter(e -> "IN".equals(e.getKind()))
|
||||
.filter(e -> !e.getEntryDate().isAfter(asOfDate) && !e.getEntryDate().isBefore(from))
|
||||
.map(LedgerEntry::getAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
if (inflow.signum() == 0) return Computed.missing(FeatureCatalog.NO_LEDGER);
|
||||
|
||||
long days = Math.max(ChronoUnit.DAYS.between(from, asOfDate), 1);
|
||||
BigDecimal monthlyInflow = inflow.divide(BigDecimal.valueOf(days), 4, RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(30));
|
||||
if (monthlyInflow.signum() == 0) return Computed.missing(FeatureCatalog.NO_LEDGER);
|
||||
|
||||
LocalDate newest = loans.stream().map(LoanRecord::getAsOfDate).max(LocalDate::compareTo).orElse(asOfDate);
|
||||
return Computed.of(instalment.divide(monthlyInflow, 4, RoundingMode.HALF_UP), staleness(newest, asOfDate));
|
||||
}
|
||||
|
||||
/**
|
||||
* The product's entire trigger, as a number: the worst month of the horizon
|
||||
* where the downside case has to cover what is due. Read from the latest
|
||||
* scoring run's stored quantile path, so it reflects the engine that actually
|
||||
* produced the forecast an officer saw.
|
||||
*/
|
||||
private Computed dscrP10(String enterpriseId, LocalDate asOfDate) {
|
||||
List<ForecastPoint> points = forecastPointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId);
|
||||
if (points.isEmpty()) return Computed.missing(FeatureCatalog.NO_FORECAST);
|
||||
|
||||
Long latestRun = points.stream().map(ForecastPoint::getRunId)
|
||||
.filter(Objects::nonNull).max(Long::compareTo).orElse(null);
|
||||
if (latestRun == null) return Computed.missing(FeatureCatalog.NO_FORECAST);
|
||||
|
||||
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);
|
||||
if (worst == null || ratio.compareTo(worst) < 0) worst = ratio;
|
||||
}
|
||||
return worst == null ? Computed.missing(FeatureCatalog.NO_FORECAST) : Computed.of(worst, 0);
|
||||
}
|
||||
|
||||
private BigDecimal sumInflow(List<UpiSummary> weeks) {
|
||||
return weeks.stream()
|
||||
.map(UpiSummary::getInflowAmount)
|
||||
.filter(Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
// ── persistence ─────────────────────────────────────────
|
||||
|
||||
private FeatureSnapshot persist(String enterpriseId, LocalDate asOfDate, LocalDateTime observedAsOf,
|
||||
Long scoringRunId, Map<String, Computed> features) {
|
||||
|
||||
FeatureSnapshot snapshot = snapshotRepository.findByEnterpriseIdAndAsOfDate(enterpriseId, asOfDate)
|
||||
.orElseGet(() -> FeatureSnapshot.builder()
|
||||
.enterpriseId(enterpriseId)
|
||||
.asOfDate(asOfDate)
|
||||
.build());
|
||||
|
||||
int present = 0, missing = 0, maxStaleness = 0;
|
||||
|
||||
for (Map.Entry<String, Computed> entry : features.entrySet()) {
|
||||
String code = entry.getKey();
|
||||
Computed computed = entry.getValue();
|
||||
|
||||
FeatureValue row = valueRepository
|
||||
.findByEnterpriseIdAndFeatureCodeAndValidForDate(enterpriseId, code, asOfDate)
|
||||
.orElseGet(() -> FeatureValue.builder()
|
||||
.enterpriseId(enterpriseId)
|
||||
.featureCode(code)
|
||||
.validForDate(asOfDate)
|
||||
.build());
|
||||
row.setValue(computed.value());
|
||||
row.setStalenessDays(computed.stalenessDays());
|
||||
row.setMissingReason(computed.missingReason());
|
||||
row.setObservedAt(observedAsOf);
|
||||
valueRepository.save(row);
|
||||
|
||||
snapshot.put(code, computed.value());
|
||||
if (computed.value() == null) missing++;
|
||||
else present++;
|
||||
if (computed.stalenessDays() != null) maxStaleness = Math.max(maxStaleness, computed.stalenessDays());
|
||||
}
|
||||
|
||||
snapshot.setScoringRunId(scoringRunId);
|
||||
snapshot.setFeatureCount(present);
|
||||
snapshot.setMissingCount(missing);
|
||||
snapshot.setMaxStalenessDays(maxStaleness);
|
||||
snapshot.setComputedAt(observedAsOf);
|
||||
return snapshotRepository.save(snapshot);
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────
|
||||
|
||||
private Integer marketStaleness(Long districtId, LocalDate asOfDate) {
|
||||
return marketSignalRepository.findFirstByDistrictIdOrderByValidForDateDesc(districtId)
|
||||
.map(signal -> staleness(signal.getValidForDate(), asOfDate))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private Integer staleness(LocalDate validFor, LocalDate asOfDate) {
|
||||
if (validFor == null) return null;
|
||||
return (int) Math.max(0, ChronoUnit.DAYS.between(validFor, asOfDate));
|
||||
}
|
||||
|
||||
private BigDecimal mean(List<BigDecimal> values) {
|
||||
if (values == null || values.isEmpty()) return null;
|
||||
return values.stream().reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.divide(BigDecimal.valueOf(values.size()), 4, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** Sample standard deviation — n−1, because these are samples of a season, not the population. */
|
||||
private BigDecimal stdev(List<BigDecimal> values, BigDecimal mean) {
|
||||
if (values.size() < 2 || mean == null) return null;
|
||||
BigDecimal sumSquares = BigDecimal.ZERO;
|
||||
for (BigDecimal v : values) {
|
||||
BigDecimal diff = v.subtract(mean);
|
||||
sumSquares = sumSquares.add(diff.multiply(diff));
|
||||
}
|
||||
double variance = sumSquares.divide(BigDecimal.valueOf(values.size() - 1L), 6, RoundingMode.HALF_UP)
|
||||
.doubleValue();
|
||||
return BigDecimal.valueOf(Math.sqrt(variance)).setScale(4, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* One feature's outcome. A null value always carries a reason — that is the
|
||||
* difference between a hole we can explain and one we cannot.
|
||||
*/
|
||||
public record Computed(BigDecimal value, Integer stalenessDays, String missingReason) {
|
||||
|
||||
static Computed of(BigDecimal value, Integer stalenessDays) {
|
||||
return new Computed(value, stalenessDays, null);
|
||||
}
|
||||
|
||||
static Computed missing(String reason) {
|
||||
return new Computed(null, null, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/** What one pass over the book did. */
|
||||
public record Summary(int snapshots, int skippedForConsent, int nullValues, String message) {}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.lincfusion.udyamlinc.features;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The wide store — one row per (enterprise, scoring run). What the risk engine
|
||||
* and, later, the trainer actually read.
|
||||
*
|
||||
* <p>The tall table alone would mean thousands of row reads per scoring pass and
|
||||
* far worse for training; this alone would mean a migration every time a feature
|
||||
* is added. Both exist on purpose, and {@link FeatureValue} remains the system of
|
||||
* record — this is a materialised read.
|
||||
*
|
||||
* <p>{@link #asFeatureMap()} exposes the same values under their snake_case
|
||||
* catalogue codes, which is what a trip-wire expression is written against.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "feature_snapshot", indexes = {
|
||||
@Index(name = "ix_feature_snap_key", columnList = "enterpriseId,asOfDate", unique = true),
|
||||
@Index(name = "ix_feature_snap_run", columnList = "scoringRunId"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class FeatureSnapshot {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate asOfDate;
|
||||
|
||||
/** Set when the snapshot was built for a specific scoring pass. */
|
||||
private Long scoringRunId;
|
||||
|
||||
// ── external shock ──────────────────────────────────────
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal feedCostChange;
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal outputPriceChange;
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal marginSqueeze;
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal priceZScore;
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal rainfallAnomaly;
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal heatAnomaly;
|
||||
|
||||
// ── liquidity & behaviour ───────────────────────────────
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal cashFlowChange;
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal emiDelay;
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal inventoryDays;
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal upiInflowChange;
|
||||
|
||||
/** Silence is a signal: an enterprise that stops recording has usually stopped trading normally. */
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal daysSinceLastEntry;
|
||||
|
||||
// ── obligation load ─────────────────────────────────────
|
||||
|
||||
/** Worst month of the horizon — min(p10 / obligation). Below 1.0 the downside case does not cover what is due. */
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal dscrP10;
|
||||
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal emiToInflow;
|
||||
|
||||
// ── the guard ───────────────────────────────────────────
|
||||
|
||||
/** Age of the newest market observation behind this row. Guards all of the above. */
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal feedStalenessDays;
|
||||
|
||||
// ── provenance ──────────────────────────────────────────
|
||||
|
||||
/** How many catalogue features carry a value. */
|
||||
private Integer featureCount;
|
||||
|
||||
/** How many came back null — a snapshot mostly made of holes must not look confident. */
|
||||
private Integer missingCount;
|
||||
|
||||
/** The oldest input age across all features, in days. */
|
||||
private Integer maxStalenessDays;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime computedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (computedAt == null) computedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue-code view, for rule evaluation and for the model. Insertion
|
||||
* ordered so a debug dump reads the way the catalogue does.
|
||||
*/
|
||||
public Map<String, Object> asFeatureMap() {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put(FeatureCatalog.FEED_COST_CHANGE, feedCostChange);
|
||||
map.put(FeatureCatalog.OUTPUT_PRICE_CHANGE, outputPriceChange);
|
||||
map.put(FeatureCatalog.MARGIN_SQUEEZE, marginSqueeze);
|
||||
map.put(FeatureCatalog.PRICE_Z_SCORE, priceZScore);
|
||||
map.put(FeatureCatalog.RAINFALL_ANOMALY, rainfallAnomaly);
|
||||
map.put(FeatureCatalog.HEAT_ANOMALY, heatAnomaly);
|
||||
map.put(FeatureCatalog.CASH_FLOW_CHANGE, cashFlowChange);
|
||||
map.put(FeatureCatalog.EMI_DELAY, emiDelay);
|
||||
map.put(FeatureCatalog.INVENTORY_DAYS, inventoryDays);
|
||||
map.put(FeatureCatalog.UPI_INFLOW_CHANGE, upiInflowChange);
|
||||
map.put(FeatureCatalog.FEED_STALENESS_DAYS, feedStalenessDays);
|
||||
map.put(FeatureCatalog.DSCR_P10, dscrP10);
|
||||
map.put(FeatureCatalog.DAYS_SINCE_LAST_ENTRY, daysSinceLastEntry);
|
||||
map.put(FeatureCatalog.EMI_TO_INFLOW, emiToInflow);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Setter counterpart, so the engineering service writes columns by code. */
|
||||
public void put(String code, BigDecimal value) {
|
||||
switch (code) {
|
||||
case FeatureCatalog.FEED_COST_CHANGE -> feedCostChange = value;
|
||||
case FeatureCatalog.OUTPUT_PRICE_CHANGE -> outputPriceChange = value;
|
||||
case FeatureCatalog.MARGIN_SQUEEZE -> marginSqueeze = value;
|
||||
case FeatureCatalog.PRICE_Z_SCORE -> priceZScore = value;
|
||||
case FeatureCatalog.RAINFALL_ANOMALY -> rainfallAnomaly = value;
|
||||
case FeatureCatalog.HEAT_ANOMALY -> heatAnomaly = value;
|
||||
case FeatureCatalog.CASH_FLOW_CHANGE -> cashFlowChange = value;
|
||||
case FeatureCatalog.EMI_DELAY -> emiDelay = value;
|
||||
case FeatureCatalog.INVENTORY_DAYS -> inventoryDays = value;
|
||||
case FeatureCatalog.UPI_INFLOW_CHANGE -> upiInflowChange = value;
|
||||
case FeatureCatalog.FEED_STALENESS_DAYS -> feedStalenessDays = value;
|
||||
case FeatureCatalog.DSCR_P10 -> dscrP10 = value;
|
||||
case FeatureCatalog.DAYS_SINCE_LAST_ENTRY -> daysSinceLastEntry = value;
|
||||
case FeatureCatalog.EMI_TO_INFLOW -> emiToInflow = value;
|
||||
default -> throw new IllegalArgumentException("Unknown feature code: " + code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.lincfusion.udyamlinc.features;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FeatureSnapshotRepository extends JpaRepository<FeatureSnapshot, Long> {
|
||||
|
||||
Optional<FeatureSnapshot> findByEnterpriseIdAndAsOfDate(String enterpriseId, LocalDate asOfDate);
|
||||
|
||||
Optional<FeatureSnapshot> findFirstByEnterpriseIdOrderByAsOfDateDesc(String enterpriseId);
|
||||
|
||||
List<FeatureSnapshot> findByAsOfDate(LocalDate asOfDate);
|
||||
|
||||
List<FeatureSnapshot> findByScoringRunId(Long scoringRunId);
|
||||
|
||||
long countByAsOfDate(LocalDate asOfDate);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.lincfusion.udyamlinc.features;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* The tall store — one row per (enterprise, feature, date). This is the system of
|
||||
* record: auditable, and schema-free as features are added.
|
||||
*
|
||||
* <p>Both timestamps are carried for the reason in ARCHITECTURE §2.3.
|
||||
* {@code stalenessDays} is computed at write time from the age of the newest
|
||||
* input the value was derived from, so a consumer never has to re-derive
|
||||
* freshness from the source tables.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "feature_value", indexes = {
|
||||
@Index(name = "ix_feature_value_key", columnList = "enterpriseId,featureCode,validForDate", unique = true),
|
||||
@Index(name = "ix_feature_value_code", columnList = "featureCode,validForDate"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class FeatureValue {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
@Column(nullable = false, length = 48)
|
||||
private String featureCode;
|
||||
|
||||
/** Null is a value: it means the inputs were absent, not that the row is missing. */
|
||||
@Column(precision = 16, scale = 4)
|
||||
private BigDecimal value;
|
||||
|
||||
/** The date the fact describes. */
|
||||
@Column(nullable = false)
|
||||
private LocalDate validForDate;
|
||||
|
||||
/** When we computed it. */
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime observedAt;
|
||||
|
||||
/** Age of the newest input behind this value, in days. */
|
||||
private Integer stalenessDays;
|
||||
|
||||
/** Why the value is null — NO_SIGNAL | NO_LEDGER | NO_CBS | NO_CONSENT | INSUFFICIENT_HISTORY */
|
||||
@Column(length = 32)
|
||||
private String missingReason;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (observedAt == null) observedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.lincfusion.udyamlinc.features;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FeatureValueRepository extends JpaRepository<FeatureValue, Long> {
|
||||
|
||||
Optional<FeatureValue> findByEnterpriseIdAndFeatureCodeAndValidForDate(
|
||||
String enterpriseId, String featureCode, LocalDate validForDate);
|
||||
|
||||
List<FeatureValue> findByEnterpriseIdAndValidForDateOrderByFeatureCode(String enterpriseId, LocalDate validForDate);
|
||||
|
||||
List<FeatureValue> findByEnterpriseIdAndFeatureCodeOrderByValidForDateDesc(String enterpriseId, String featureCode);
|
||||
|
||||
/** The training read: one feature's history for one enterprise, point-in-time. */
|
||||
@Query("""
|
||||
select f from FeatureValue f
|
||||
where f.enterpriseId = :enterpriseId and f.featureCode = :featureCode
|
||||
and f.validForDate <= :asOfDate and f.observedAt <= :asOf
|
||||
order by f.validForDate desc
|
||||
""")
|
||||
List<FeatureValue> findTrailing(@Param("enterpriseId") String enterpriseId,
|
||||
@Param("featureCode") String featureCode,
|
||||
@Param("asOfDate") LocalDate asOfDate,
|
||||
@Param("asOf") LocalDateTime asOf);
|
||||
|
||||
long countByValidForDate(LocalDate validForDate);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One outbound call to an external feed. Kept because "the mandi price looked
|
||||
* wrong on Tuesday" is only answerable if we recorded what we asked and what
|
||||
* came back. The API key is never stored — the URL is redacted before write.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "api_log", indexes = {
|
||||
@Index(name = "ix_api_log_source_time", columnList = "source,calledAt"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ApiLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** AGMARKNET | OPEN_METEO | NECC | IMD */
|
||||
@Column(nullable = false, length = 32)
|
||||
private String source;
|
||||
|
||||
@Column(length = 8)
|
||||
private String method;
|
||||
|
||||
/** Redacted — the api-key value is replaced before this is written. */
|
||||
@Column(length = 1024)
|
||||
private String url;
|
||||
|
||||
private Integer httpStatus;
|
||||
|
||||
private Long durationMs;
|
||||
|
||||
/** Records the response carried, before any filtering. */
|
||||
private Integer recordCount;
|
||||
|
||||
private Boolean success;
|
||||
|
||||
@Column(length = 1024)
|
||||
private String error;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime calledAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (calledAt == null) calledAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ApiLogRepository extends JpaRepository<ApiLog, Long> {
|
||||
|
||||
List<ApiLog> findBySourceOrderByCalledAtDesc(String source, Pageable pageable);
|
||||
|
||||
List<ApiLog> findAllByOrderByCalledAtDesc(Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import com.lincfusion.udyamlinc.ingestion.client.DailyWeather;
|
||||
import com.lincfusion.udyamlinc.ingestion.client.OpenMeteoClient;
|
||||
import com.lincfusion.udyamlinc.master.District;
|
||||
import com.lincfusion.udyamlinc.master.DistrictRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Pulls the district weather series and folds it into weekly
|
||||
* {@link ClimateSignal} rows, each carrying its 30-year normal.
|
||||
*
|
||||
* <p>The normal is computed once per district from a single baseline request and
|
||||
* cached in {@link ClimateNormal}; nightly runs only fetch the recent window. An
|
||||
* absolute rainfall figure is not a feature — "40 % below normal for this week
|
||||
* of the year" is, and that needs the baseline sitting next to the observation.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ClimateIngestionService {
|
||||
|
||||
private final OpenMeteoClient client;
|
||||
private final DistrictRepository districtRepository;
|
||||
private final ClimateSignalRepository signalRepository;
|
||||
private final ClimateNormalRepository normalRepository;
|
||||
|
||||
@Value("${udyam.ingestion.climate.lookback-weeks:12}")
|
||||
private int lookbackWeeks;
|
||||
|
||||
/** The archive trails real time; asking for yesterday returns nothing. */
|
||||
@Value("${udyam.ingestion.climate.lag-days:5}")
|
||||
private int lagDays;
|
||||
|
||||
@Value("${udyam.ingestion.climate.heat-threshold-c:40}")
|
||||
private double heatThresholdC;
|
||||
|
||||
@Value("${udyam.ingestion.climate.baseline-from:1995-01-01}")
|
||||
private String baselineFrom;
|
||||
|
||||
@Value("${udyam.ingestion.climate.baseline-to:2024-12-31}")
|
||||
private String baselineTo;
|
||||
|
||||
/** Not one transaction, for the same reason as {@link MandiIngestionService#ingest()}. */
|
||||
public IngestionResult ingest() {
|
||||
List<District> districts = districtRepository.findByActiveTrue();
|
||||
if (districts.isEmpty()) {
|
||||
return new IngestionResult(0, 0, 0, 0, "No active districts");
|
||||
}
|
||||
|
||||
LocalDate to = LocalDate.now().minusDays(lagDays);
|
||||
LocalDate from = Weeks.startOf(to).minusWeeks(lookbackWeeks);
|
||||
LocalDateTime observedAt = LocalDateTime.now();
|
||||
int fetched = 0, written = 0, skipped = 0, failed = 0;
|
||||
|
||||
for (District district : districts) {
|
||||
if (district.getLatitude() == null || district.getLongitude() == null) {
|
||||
skipped++;
|
||||
log.warn("District {} has no centroid — cannot pick a climate grid cell", district.getCode());
|
||||
continue;
|
||||
}
|
||||
|
||||
ensureNormals(district);
|
||||
|
||||
List<DailyWeather> days = client.fetchDaily(
|
||||
district.getLatitude(), district.getLongitude(), from, to);
|
||||
if (days == null) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
fetched += days.size();
|
||||
|
||||
Map<LocalDate, List<DailyWeather>> byWeek = days.stream()
|
||||
.collect(Collectors.groupingBy(d -> Weeks.startOf(d.date())));
|
||||
|
||||
for (Map.Entry<LocalDate, List<DailyWeather>> week : byWeek.entrySet()) {
|
||||
upsert(district, week.getKey(), week.getValue(), observedAt);
|
||||
written++;
|
||||
}
|
||||
}
|
||||
|
||||
String message = "%d districts · %d days fetched · %d weeks written · %d calls failed"
|
||||
.formatted(districts.size(), fetched, written, failed);
|
||||
log.info("Climate ingestion — {}", message);
|
||||
return new IngestionResult(fetched, written, skipped, failed, message);
|
||||
}
|
||||
|
||||
// ── weekly observation ──────────────────────────────────
|
||||
|
||||
private void upsert(District district, LocalDate weekStart, List<DailyWeather> days, LocalDateTime observedAt) {
|
||||
int isoWeek = Weeks.isoWeek(weekStart);
|
||||
|
||||
BigDecimal rain = round(days.stream().map(DailyWeather::rainfallMm)
|
||||
.filter(Objects::nonNull).mapToDouble(Double::doubleValue).sum(), 2);
|
||||
Double tmaxMean = days.stream().map(DailyWeather::tmaxC).filter(Objects::nonNull)
|
||||
.mapToDouble(Double::doubleValue).average().orElse(Double.NaN);
|
||||
Double tminMean = days.stream().map(DailyWeather::tminC).filter(Objects::nonNull)
|
||||
.mapToDouble(Double::doubleValue).average().orElse(Double.NaN);
|
||||
int hotDays = (int) days.stream().map(DailyWeather::tmaxC)
|
||||
.filter(Objects::nonNull).filter(t -> t >= heatThresholdC).count();
|
||||
|
||||
ClimateSignal signal = signalRepository
|
||||
.findByDistrictIdAndWeekStartAndSource(district.getId(), weekStart, OpenMeteoClient.SOURCE)
|
||||
.orElseGet(() -> ClimateSignal.builder()
|
||||
.districtId(district.getId())
|
||||
.weekStart(weekStart)
|
||||
.source(OpenMeteoClient.SOURCE)
|
||||
.build());
|
||||
|
||||
signal.setIsoWeek(isoWeek);
|
||||
signal.setRainfallMm(rain);
|
||||
signal.setTmaxC(Double.isNaN(tmaxMean) ? null : round(tmaxMean, 2));
|
||||
signal.setTminC(Double.isNaN(tminMean) ? null : round(tminMean, 2));
|
||||
signal.setHotDays(hotDays);
|
||||
signal.setDayCount(days.size());
|
||||
signal.setValidForDate(days.stream().map(DailyWeather::date).max(LocalDate::compareTo).orElse(weekStart));
|
||||
signal.setObservedAt(observedAt);
|
||||
|
||||
normalRepository.findByDistrictIdAndIsoWeek(district.getId(), isoWeek).ifPresent(normal -> {
|
||||
signal.setRainfallNormalMm(normal.getNormalRainfallMm());
|
||||
signal.setTmaxNormalC(normal.getNormalTmaxC());
|
||||
if (normal.getNormalRainfallMm() != null && normal.getNormalRainfallMm().signum() > 0 && rain != null) {
|
||||
signal.setRainfallAnomalyPct(rain.subtract(normal.getNormalRainfallMm())
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.divide(normal.getNormalRainfallMm(), 2, RoundingMode.HALF_UP));
|
||||
}
|
||||
if (normal.getNormalTmaxC() != null && signal.getTmaxC() != null) {
|
||||
signal.setHeatAnomalyC(signal.getTmaxC().subtract(normal.getNormalTmaxC()));
|
||||
}
|
||||
});
|
||||
|
||||
signalRepository.save(signal);
|
||||
}
|
||||
|
||||
// ── the 30-year baseline ────────────────────────────────
|
||||
|
||||
/** One request per district, ever. Skipped entirely once normals are stored. */
|
||||
private void ensureNormals(District district) {
|
||||
if (normalRepository.countByDistrictId(district.getId()) > 0) return;
|
||||
|
||||
LocalDate from = LocalDate.parse(baselineFrom);
|
||||
LocalDate to = LocalDate.parse(baselineTo);
|
||||
log.info("Computing climate normals for {} from {}..{} — one-time", district.getCode(), from, to);
|
||||
|
||||
List<DailyWeather> days = client.fetchDaily(district.getLatitude(), district.getLongitude(), from, to);
|
||||
if (days == null || days.isEmpty()) {
|
||||
log.warn("No baseline series for {} — anomalies stay null until this succeeds", district.getCode());
|
||||
return;
|
||||
}
|
||||
|
||||
// year-week → that year's weekly totals, then mean across years for each ISO week
|
||||
Map<String, List<DailyWeather>> byYearWeek = days.stream()
|
||||
.collect(Collectors.groupingBy(d -> Weeks.isoYear(d.date()) + "-" + Weeks.isoWeek(d.date())));
|
||||
|
||||
Map<Integer, List<Double>> rainByWeek = new HashMap<>();
|
||||
Map<Integer, List<Double>> tmaxByWeek = new HashMap<>();
|
||||
Map<Integer, Set<Integer>> yearsByWeek = new HashMap<>();
|
||||
|
||||
byYearWeek.forEach((key, group) -> {
|
||||
int year = Integer.parseInt(key.substring(0, key.indexOf('-')));
|
||||
int week = Integer.parseInt(key.substring(key.indexOf('-') + 1));
|
||||
double weeklyRain = group.stream().map(DailyWeather::rainfallMm)
|
||||
.filter(Objects::nonNull).mapToDouble(Double::doubleValue).sum();
|
||||
OptionalDouble weeklyTmax = group.stream().map(DailyWeather::tmaxC)
|
||||
.filter(Objects::nonNull).mapToDouble(Double::doubleValue).average();
|
||||
rainByWeek.computeIfAbsent(week, k -> new ArrayList<>()).add(weeklyRain);
|
||||
weeklyTmax.ifPresent(v -> tmaxByWeek.computeIfAbsent(week, k -> new ArrayList<>()).add(v));
|
||||
yearsByWeek.computeIfAbsent(week, k -> new HashSet<>()).add(year);
|
||||
});
|
||||
|
||||
List<ClimateNormal> normals = new ArrayList<>();
|
||||
for (Map.Entry<Integer, List<Double>> e : rainByWeek.entrySet()) {
|
||||
int week = e.getKey();
|
||||
normals.add(ClimateNormal.builder()
|
||||
.districtId(district.getId())
|
||||
.isoWeek(week)
|
||||
.normalRainfallMm(round(e.getValue().stream().mapToDouble(Double::doubleValue).average().orElse(0), 2))
|
||||
.normalTmaxC(tmaxByWeek.containsKey(week)
|
||||
? round(tmaxByWeek.get(week).stream().mapToDouble(Double::doubleValue).average().orElse(0), 2)
|
||||
: null)
|
||||
.years(yearsByWeek.getOrDefault(week, Set.of()).size())
|
||||
.baselineFrom(from)
|
||||
.baselineTo(to)
|
||||
.build());
|
||||
}
|
||||
normalRepository.saveAll(normals);
|
||||
log.info("Stored {} weekly normals for {}", normals.size(), district.getCode());
|
||||
}
|
||||
|
||||
private BigDecimal round(double value, int scale) {
|
||||
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* The 30-year baseline for one district and ISO week. Computed once per district
|
||||
* from a single archive request over the baseline period, then reused — an
|
||||
* anomaly is meaningless without it, and re-fetching decades of history nightly
|
||||
* would be absurd.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "climate_normal", indexes = {
|
||||
@Index(name = "ix_climate_normal_key", columnList = "districtId,isoWeek", unique = true),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ClimateNormal {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long districtId;
|
||||
|
||||
/** 1–53. */
|
||||
@Column(nullable = false)
|
||||
private Integer isoWeek;
|
||||
|
||||
/** Mean weekly rainfall across the baseline years. */
|
||||
@Column(precision = 8, scale = 2)
|
||||
private BigDecimal normalRainfallMm;
|
||||
|
||||
@Column(precision = 6, scale = 2)
|
||||
private BigDecimal normalTmaxC;
|
||||
|
||||
/** How many baseline years contributed — a thin normal is a weak normal. */
|
||||
private Integer years;
|
||||
|
||||
private LocalDate baselineFrom;
|
||||
|
||||
private LocalDate baselineTo;
|
||||
|
||||
private LocalDateTime computedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (computedAt == null) computedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ClimateNormalRepository extends JpaRepository<ClimateNormal, Long> {
|
||||
|
||||
Optional<ClimateNormal> findByDistrictIdAndIsoWeek(Long districtId, Integer isoWeek);
|
||||
|
||||
List<ClimateNormal> findByDistrictId(Long districtId);
|
||||
|
||||
long countByDistrictId(Long districtId);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A week of weather for one district, with the 30-year normal alongside the
|
||||
* observation. IMD supplies the advisory text; the numbers come from a
|
||||
* reanalysis archive, because anomalies need decades of history that live
|
||||
* endpoints do not serve.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "climate_signal", indexes = {
|
||||
@Index(name = "ix_climate_signal_key", columnList = "districtId,weekStart,source", unique = true),
|
||||
@Index(name = "ix_climate_signal_week", columnList = "weekStart"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ClimateSignal {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long districtId;
|
||||
|
||||
/** Monday of the ISO week. */
|
||||
@Column(nullable = false)
|
||||
private LocalDate weekStart;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer isoWeek;
|
||||
|
||||
@Column(precision = 8, scale = 2)
|
||||
private BigDecimal rainfallMm;
|
||||
|
||||
@Column(precision = 6, scale = 2)
|
||||
private BigDecimal tmaxC;
|
||||
|
||||
@Column(precision = 6, scale = 2)
|
||||
private BigDecimal tminC;
|
||||
|
||||
/** Days in the week above the heat threshold. */
|
||||
private Integer hotDays;
|
||||
|
||||
// ── the 30-year normal for this district and ISO week, copied in at write time ──
|
||||
|
||||
@Column(precision = 8, scale = 2)
|
||||
private BigDecimal rainfallNormalMm;
|
||||
|
||||
@Column(precision = 6, scale = 2)
|
||||
private BigDecimal tmaxNormalC;
|
||||
|
||||
/** (observed − normal) / normal, as a percentage. Null when no normal is held. */
|
||||
@Column(precision = 8, scale = 2)
|
||||
private BigDecimal rainfallAnomalyPct;
|
||||
|
||||
/** Observed hot days minus the normal's hot days. */
|
||||
@Column(precision = 6, scale = 2)
|
||||
private BigDecimal heatAnomalyC;
|
||||
|
||||
/** How many daily observations backed this week — a short week is a partial week. */
|
||||
private Integer dayCount;
|
||||
|
||||
@Column(nullable = false, length = 32)
|
||||
private String source;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate validForDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime observedAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (observedAt == null) observedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ClimateSignalRepository extends JpaRepository<ClimateSignal, Long> {
|
||||
|
||||
Optional<ClimateSignal> findByDistrictIdAndWeekStartAndSource(Long districtId, LocalDate weekStart, String source);
|
||||
|
||||
List<ClimateSignal> findByDistrictIdOrderByWeekStartDesc(Long districtId);
|
||||
|
||||
List<ClimateSignal> findByWeekStartGreaterThanEqualOrderByWeekStartDesc(LocalDate from);
|
||||
|
||||
/** Point-in-time trailing window, same rule as {@link MarketSignalRepository#findTrailing}. */
|
||||
@Query("""
|
||||
select s from ClimateSignal s
|
||||
where s.districtId = :districtId
|
||||
and s.validForDate <= :asOfDate and s.observedAt <= :asOf
|
||||
order by s.weekStart desc
|
||||
""")
|
||||
List<ClimateSignal> findTrailing(@Param("districtId") Long districtId,
|
||||
@Param("asOfDate") LocalDate asOfDate,
|
||||
@Param("asOf") LocalDateTime asOf);
|
||||
|
||||
Optional<ClimateSignal> findFirstByDistrictIdOrderByValidForDateDesc(Long districtId);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ApiResponse;
|
||||
import com.lincfusion.udyamlinc.master.District;
|
||||
import com.lincfusion.udyamlinc.master.DistrictRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The operations surface for ingestion: what ran, what it fetched, and how stale
|
||||
* each district's data now is.
|
||||
*
|
||||
* <p>Mounted under {@code /admin} so the existing rule applies — any officer may
|
||||
* read, only a supervisor may trigger a run.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/ingestion")
|
||||
@RequiredArgsConstructor
|
||||
public class IngestionController {
|
||||
|
||||
private final IngestionScheduler scheduler;
|
||||
private final IngestionRunRepository runRepository;
|
||||
private final MarketSignalRepository marketSignalRepository;
|
||||
private final ClimateSignalRepository climateSignalRepository;
|
||||
private final ApiLogRepository apiLogRepository;
|
||||
private final DistrictRepository districtRepository;
|
||||
|
||||
// ── what ran ────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/runs")
|
||||
public ApiResponse<List<IngestionRun>> runs(@RequestParam(required = false) String source,
|
||||
@RequestParam(defaultValue = "30") int limit) {
|
||||
PageRequest page = PageRequest.of(0, Math.min(limit, 200));
|
||||
return ApiResponse.ok(source == null
|
||||
? runRepository.findAllByOrderByStartedAtDesc(page)
|
||||
: runRepository.findBySourceOrderByStartedAtDesc(source, page));
|
||||
}
|
||||
|
||||
@GetMapping("/calls")
|
||||
public ApiResponse<List<ApiLog>> calls(@RequestParam(required = false) String source,
|
||||
@RequestParam(defaultValue = "50") int limit) {
|
||||
PageRequest page = PageRequest.of(0, Math.min(limit, 200));
|
||||
return ApiResponse.ok(source == null
|
||||
? apiLogRepository.findAllByOrderByCalledAtDesc(page)
|
||||
: apiLogRepository.findBySourceOrderByCalledAtDesc(source, page));
|
||||
}
|
||||
|
||||
// ── what it wrote ───────────────────────────────────────
|
||||
|
||||
@GetMapping("/signals/market")
|
||||
public ApiResponse<List<MarketSignal>> marketSignals(@RequestParam(defaultValue = "12") int weeks) {
|
||||
return ApiResponse.ok(marketSignalRepository
|
||||
.findByWeekStartGreaterThanEqualOrderByWeekStartDesc(Weeks.startOf(LocalDate.now()).minusWeeks(weeks)));
|
||||
}
|
||||
|
||||
@GetMapping("/signals/climate")
|
||||
public ApiResponse<List<ClimateSignal>> climateSignals(@RequestParam(defaultValue = "12") int weeks) {
|
||||
return ApiResponse.ok(climateSignalRepository
|
||||
.findByWeekStartGreaterThanEqualOrderByWeekStartDesc(Weeks.startOf(LocalDate.now()).minusWeeks(weeks)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-district freshness — the number a trip-wire consults before it is
|
||||
* allowed to fire. A rule firing on a three-week-old price is worse than no
|
||||
* rule.
|
||||
*/
|
||||
@GetMapping("/freshness")
|
||||
public ApiResponse<List<Map<String, Object>>> freshness() {
|
||||
LocalDate today = LocalDate.now();
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (District district : districtRepository.findByActiveTrue()) {
|
||||
LocalDate market = marketSignalRepository
|
||||
.findFirstByDistrictIdOrderByValidForDateDesc(district.getId())
|
||||
.map(MarketSignal::getValidForDate).orElse(null);
|
||||
LocalDate climate = climateSignalRepository
|
||||
.findFirstByDistrictIdOrderByValidForDateDesc(district.getId())
|
||||
.map(ClimateSignal::getValidForDate).orElse(null);
|
||||
Map<String, Object> row = new java.util.LinkedHashMap<>();
|
||||
row.put("districtCode", district.getCode());
|
||||
row.put("districtName", district.getName());
|
||||
row.put("marketValidFor", market);
|
||||
row.put("marketStalenessDays", market == null ? null : ChronoUnit.DAYS.between(market, today));
|
||||
row.put("climateValidFor", climate);
|
||||
row.put("climateStalenessDays", climate == null ? null : ChronoUnit.DAYS.between(climate, today));
|
||||
out.add(row);
|
||||
}
|
||||
return ApiResponse.ok(out);
|
||||
}
|
||||
|
||||
// ── trigger ─────────────────────────────────────────────
|
||||
|
||||
/** Supervisor only, by the {@code /admin/**} write rule in SecurityConfig. */
|
||||
@PostMapping("/run")
|
||||
public ApiResponse<List<IngestionRun>> runAll() {
|
||||
return ApiResponse.ok("Ingestion triggered", scheduler.runAll("MANUAL"));
|
||||
}
|
||||
|
||||
@PostMapping("/run/{source}")
|
||||
public ApiResponse<IngestionRun> runOne(@PathVariable String source) {
|
||||
return ApiResponse.ok("Ingestion triggered", scheduler.run(source.toUpperCase(), "MANUAL"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
/** What one ingestion pass did, on its way into an {@link IngestionRun} row. */
|
||||
public record IngestionResult(int fetched, int written, int skipped, int callsFailed, String message) {
|
||||
|
||||
/** A pass that wrote nothing and lost no calls did not really run. */
|
||||
public String status() {
|
||||
if (callsFailed > 0 && written == 0) return IngestionRun.FAILED;
|
||||
if (callsFailed > 0) return IngestionRun.PARTIAL;
|
||||
return IngestionRun.SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One pass of the nightly job for one source. This is the table the console's
|
||||
* freshness indicator reads: a scoring run that quietly used three-week-old
|
||||
* prices is the failure mode this exists to make visible.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "ingestion_run", indexes = {
|
||||
@Index(name = "ix_ingestion_run_source_date", columnList = "source,runDate"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class IngestionRun {
|
||||
|
||||
public static final String SUCCESS = "SUCCESS";
|
||||
public static final String PARTIAL = "PARTIAL";
|
||||
public static final String FAILED = "FAILED";
|
||||
public static final String SKIPPED = "SKIPPED";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** AGMARKNET | OPEN_METEO */
|
||||
@Column(nullable = false, length = 32)
|
||||
private String source;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate runDate;
|
||||
|
||||
/** SCHEDULED | MANUAL */
|
||||
@Column(length = 16)
|
||||
private String trigger;
|
||||
|
||||
@Column(nullable = false, length = 16)
|
||||
private String status;
|
||||
|
||||
private Integer rowsFetched;
|
||||
|
||||
private Integer rowsWritten;
|
||||
|
||||
private Integer rowsSkipped;
|
||||
|
||||
/** Upstream calls that failed — a partial run still writes what it got. */
|
||||
private Integer callsFailed;
|
||||
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
private LocalDateTime finishedAt;
|
||||
|
||||
private Long durationMs;
|
||||
|
||||
@Column(length = 1024)
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface IngestionRunRepository extends JpaRepository<IngestionRun, Long> {
|
||||
|
||||
List<IngestionRun> findAllByOrderByStartedAtDesc(Pageable pageable);
|
||||
|
||||
List<IngestionRun> findBySourceOrderByStartedAtDesc(String source, Pageable pageable);
|
||||
|
||||
Optional<IngestionRun> findFirstBySourceAndStatusOrderByStartedAtDesc(String source, String status);
|
||||
|
||||
Optional<IngestionRun> findFirstBySourceOrderByStartedAtDesc(String source);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import com.lincfusion.udyamlinc.ingestion.client.DataGovClient;
|
||||
import com.lincfusion.udyamlinc.ingestion.client.OpenMeteoClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* The 02:00 job. This is the only thing that calls an external feed — scoring and
|
||||
* login read the signal tables and nothing else, so a government endpoint being
|
||||
* down degrades tomorrow's freshness rather than failing a user request.
|
||||
*
|
||||
* <p>Off by default ({@code udyam.ingestion.enabled=false}) so a demo or a test
|
||||
* boot never calls out. The manual trigger on {@code /admin/ingestion/run} works
|
||||
* regardless — that is how you prove the wiring without waiting for 02:00.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IngestionScheduler {
|
||||
|
||||
private final MandiIngestionService mandiService;
|
||||
private final ClimateIngestionService climateService;
|
||||
private final DataGovClient dataGovClient;
|
||||
private final IngestionRunRepository runRepository;
|
||||
|
||||
/** One pass at a time. A second trigger while a run is live is refused, not queued. */
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
@Value("${udyam.ingestion.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Scheduled(cron = "${udyam.ingestion.cron:0 0 2 * * *}", zone = "${udyam.ingestion.zone:Asia/Kolkata}")
|
||||
public void nightly() {
|
||||
if (!enabled) {
|
||||
log.debug("Nightly ingestion skipped — udyam.ingestion.enabled=false");
|
||||
return;
|
||||
}
|
||||
runAll("SCHEDULED");
|
||||
}
|
||||
|
||||
/** Both sources, in order. Used by the scheduler and by the admin trigger. */
|
||||
public List<IngestionRun> runAll(String trigger) {
|
||||
return List.of(
|
||||
run(DataGovClient.SOURCE, trigger),
|
||||
run(OpenMeteoClient.SOURCE, trigger));
|
||||
}
|
||||
|
||||
/**
|
||||
* One source, recorded as an {@link IngestionRun} whatever the outcome —
|
||||
* including the skip, because "nothing ran last night" and "it ran and found
|
||||
* nothing" must not look the same in the console.
|
||||
*/
|
||||
public IngestionRun run(String source, String trigger) {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return record(source, trigger, IngestionRun.SKIPPED,
|
||||
new IngestionResult(0, 0, 0, 0, "An ingestion pass is already running"),
|
||||
LocalDateTime.now());
|
||||
}
|
||||
|
||||
LocalDateTime started = LocalDateTime.now();
|
||||
try {
|
||||
if (DataGovClient.SOURCE.equals(source) && !dataGovClient.configured()) {
|
||||
return record(source, trigger, IngestionRun.SKIPPED,
|
||||
new IngestionResult(0, 0, 0, 0,
|
||||
"No data.gov.in API key — set udyam.ingestion.datagov.api-key or DATAGOV_API_KEY"),
|
||||
started);
|
||||
}
|
||||
|
||||
IngestionResult result = switch (source) {
|
||||
case DataGovClient.SOURCE -> mandiService.ingest();
|
||||
case OpenMeteoClient.SOURCE -> climateService.ingest();
|
||||
default -> throw new IllegalArgumentException("Unknown ingestion source: " + source);
|
||||
};
|
||||
return record(source, trigger, result.status(), result, started);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Ingestion pass failed for {}", source, e);
|
||||
return record(source, trigger, IngestionRun.FAILED,
|
||||
new IngestionResult(0, 0, 0, 1, e.toString()), started);
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private IngestionRun record(String source, String trigger, String status,
|
||||
IngestionResult result, LocalDateTime started) {
|
||||
LocalDateTime finished = LocalDateTime.now();
|
||||
String message = result.message();
|
||||
return runRepository.save(IngestionRun.builder()
|
||||
.source(source)
|
||||
.runDate(LocalDate.now())
|
||||
.trigger(trigger)
|
||||
.status(status)
|
||||
.rowsFetched(result.fetched())
|
||||
.rowsWritten(result.written())
|
||||
.rowsSkipped(result.skipped())
|
||||
.callsFailed(result.callsFailed())
|
||||
.startedAt(started)
|
||||
.finishedAt(finished)
|
||||
.durationMs(java.time.Duration.between(started, finished).toMillis())
|
||||
.message(message == null || message.length() <= 1000 ? message : message.substring(0, 1000))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import com.lincfusion.udyamlinc.ingestion.client.DataGovClient;
|
||||
import com.lincfusion.udyamlinc.ingestion.client.MandiRecord;
|
||||
import com.lincfusion.udyamlinc.master.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Pulls Agmarknet daily arrivals and folds them into one weekly
|
||||
* {@link MarketSignal} per (district, commodity).
|
||||
*
|
||||
* <p>Weekly, not daily, for two reasons: a mandi does not report every day, so a
|
||||
* daily series is mostly holes; and every feature that consumes this — feed cost
|
||||
* change, margin squeeze, price z-score — is defined against a trailing weekly
|
||||
* mean anyway.
|
||||
*
|
||||
* <p>Upserts on (district, commodity, week, source), so re-running the job on the
|
||||
* same day corrects a partial week instead of duplicating it. {@code observedAt}
|
||||
* is stamped fresh on every write, which is what lets a later scoring run tell
|
||||
* the difference between a price we held on Tuesday and one that arrived today.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MandiIngestionService {
|
||||
|
||||
private final DataGovClient client;
|
||||
private final DistrictRepository districtRepository;
|
||||
private final CommodityRepository commodityRepository;
|
||||
private final MarketRepository marketRepository;
|
||||
private final MarketSignalRepository signalRepository;
|
||||
|
||||
@Value("${udyam.ingestion.mandi.lookback-weeks:12}")
|
||||
private int lookbackWeeks;
|
||||
|
||||
/**
|
||||
* One pass over every active district × Agmarknet commodity. Failures are
|
||||
* counted, not thrown: a partial run still writes what it got.
|
||||
*
|
||||
* <p>Deliberately not one transaction. The loop is dominated by outbound HTTP,
|
||||
* and holding a database connection open across a slow government endpoint is
|
||||
* how a nightly job takes the pool down. Each upsert commits on its own.
|
||||
*/
|
||||
public IngestionResult ingest() {
|
||||
List<District> districts = districtRepository.findByActiveTrue();
|
||||
List<Commodity> commodities = commodityRepository.findBySourceAndActiveTrue(DataGovClient.SOURCE);
|
||||
|
||||
if (districts.isEmpty() || commodities.isEmpty()) {
|
||||
return new IngestionResult(0, 0, 0, 0, "Nothing to pull — " + districts.size()
|
||||
+ " active districts, " + commodities.size() + " Agmarknet commodities");
|
||||
}
|
||||
|
||||
LocalDate horizon = Weeks.startOf(LocalDate.now()).minusWeeks(lookbackWeeks);
|
||||
LocalDateTime observedAt = LocalDateTime.now();
|
||||
int fetched = 0, written = 0, skipped = 0, failed = 0;
|
||||
|
||||
for (District district : districts) {
|
||||
Long marketId = marketRepository.findFirstByDistrictIdAndPrimaryMarketTrue(district.getId())
|
||||
.map(Market::getId).orElse(null);
|
||||
|
||||
for (Commodity commodity : commodities) {
|
||||
String sourceCode = commodity.getSourceCode() == null ? commodity.getName() : commodity.getSourceCode();
|
||||
List<MandiRecord> records = client.fetchPrices(district.getState(), district.getName(), sourceCode);
|
||||
|
||||
if (records == null) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
fetched += records.size();
|
||||
|
||||
Map<LocalDate, List<MandiRecord>> byWeek = records.stream()
|
||||
.filter(r -> !Weeks.startOf(r.arrivalDate()).isBefore(horizon))
|
||||
.collect(Collectors.groupingBy(r -> Weeks.startOf(r.arrivalDate())));
|
||||
|
||||
if (byWeek.isEmpty()) {
|
||||
skipped += records.size();
|
||||
continue;
|
||||
}
|
||||
|
||||
for (Map.Entry<LocalDate, List<MandiRecord>> week : byWeek.entrySet()) {
|
||||
upsert(district, commodity, marketId, week.getKey(), week.getValue(), observedAt);
|
||||
written++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String message = "%d districts × %d commodities · %d rows fetched · %d weeks written · %d calls failed"
|
||||
.formatted(districts.size(), commodities.size(), fetched, written, failed);
|
||||
log.info("Agmarknet ingestion — {}", message);
|
||||
return new IngestionResult(fetched, written, skipped, failed, message);
|
||||
}
|
||||
|
||||
private void upsert(District district, Commodity commodity, Long marketId,
|
||||
LocalDate weekStart, List<MandiRecord> rows, LocalDateTime observedAt) {
|
||||
|
||||
BigDecimal modal = mean(rows.stream().map(MandiRecord::modalPrice).filter(Objects::nonNull).toList());
|
||||
BigDecimal min = rows.stream().map(MandiRecord::minPrice).filter(Objects::nonNull)
|
||||
.min(BigDecimal::compareTo).orElse(null);
|
||||
BigDecimal max = rows.stream().map(MandiRecord::maxPrice).filter(Objects::nonNull)
|
||||
.max(BigDecimal::compareTo).orElse(null);
|
||||
LocalDate latestArrival = rows.stream().map(MandiRecord::arrivalDate).filter(Objects::nonNull)
|
||||
.max(LocalDate::compareTo).orElse(weekStart);
|
||||
|
||||
MarketSignal signal = signalRepository
|
||||
.findByDistrictIdAndCommodityIdAndWeekStartAndSource(
|
||||
district.getId(), commodity.getId(), weekStart, DataGovClient.SOURCE)
|
||||
.orElseGet(() -> MarketSignal.builder()
|
||||
.districtId(district.getId())
|
||||
.commodityId(commodity.getId())
|
||||
.weekStart(weekStart)
|
||||
.source(DataGovClient.SOURCE)
|
||||
.build());
|
||||
|
||||
signal.setMarketId(marketId);
|
||||
signal.setModalPrice(modal);
|
||||
signal.setMinPrice(min);
|
||||
signal.setMaxPrice(max);
|
||||
signal.setArrivalCount(rows.size());
|
||||
signal.setUnit(commodity.getUnit());
|
||||
signal.setValidForDate(latestArrival);
|
||||
signal.setObservedAt(observedAt);
|
||||
signalRepository.save(signal);
|
||||
}
|
||||
|
||||
private BigDecimal mean(List<BigDecimal> values) {
|
||||
if (values.isEmpty()) return null;
|
||||
BigDecimal sum = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
return sum.divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A week of mandi prices for one (district, commodity). Pulled per district —
|
||||
* never per enterprise — which is what keeps ingestion to roughly forty rows a
|
||||
* day for the pilot's four districts.
|
||||
*
|
||||
* <p>Carries both timestamps demanded by point-in-time correctness:
|
||||
* {@code validForDate} is the latest arrival the week's prices describe,
|
||||
* {@code observedAt} is when we learned it. A price for 3 March fetched on
|
||||
* 7 March must stay invisible to a 4 March scoring run.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "market_signal", indexes = {
|
||||
@Index(name = "ix_market_signal_key", columnList = "districtId,commodityId,weekStart,source", unique = true),
|
||||
@Index(name = "ix_market_signal_week", columnList = "weekStart"),
|
||||
@Index(name = "ix_market_signal_observed", columnList = "observedAt"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class MarketSignal {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long districtId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long commodityId;
|
||||
|
||||
/** The reporting mandi, when the feed named one we recognise. */
|
||||
private Long marketId;
|
||||
|
||||
/** Monday of the ISO week these prices belong to. */
|
||||
@Column(nullable = false)
|
||||
private LocalDate weekStart;
|
||||
|
||||
@Column(precision = 12, scale = 2)
|
||||
private BigDecimal modalPrice;
|
||||
|
||||
@Column(precision = 12, scale = 2)
|
||||
private BigDecimal minPrice;
|
||||
|
||||
@Column(precision = 12, scale = 2)
|
||||
private BigDecimal maxPrice;
|
||||
|
||||
/** How many daily arrivals were aggregated into this row — thin weeks are less trustworthy. */
|
||||
private Integer arrivalCount;
|
||||
|
||||
/** QUINTAL | KG | HUNDRED | LITRE — copied from the commodity at write time. */
|
||||
@Column(length = 16)
|
||||
private String unit;
|
||||
|
||||
/** AGMARKNET | NECC | DAIRY_FEDERATION | MANUAL */
|
||||
@Column(nullable = false, length = 32)
|
||||
private String source;
|
||||
|
||||
/** The date the fact describes — the latest arrival date inside the week. */
|
||||
@Column(nullable = false)
|
||||
private LocalDate validForDate;
|
||||
|
||||
/** When we learned it. */
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime observedAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (observedAt == null) observedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface MarketSignalRepository extends JpaRepository<MarketSignal, Long> {
|
||||
|
||||
Optional<MarketSignal> findByDistrictIdAndCommodityIdAndWeekStartAndSource(
|
||||
Long districtId, Long commodityId, LocalDate weekStart, String source);
|
||||
|
||||
List<MarketSignal> findByDistrictIdAndCommodityIdOrderByWeekStartDesc(Long districtId, Long commodityId);
|
||||
|
||||
List<MarketSignal> findByWeekStartGreaterThanEqualOrderByWeekStartDesc(LocalDate from);
|
||||
|
||||
/**
|
||||
* The trailing window a feature derivation reads, filtered point-in-time:
|
||||
* only rows we had actually observed by {@code asOf} are visible.
|
||||
*/
|
||||
@Query("""
|
||||
select s from MarketSignal s
|
||||
where s.districtId = :districtId and s.commodityId = :commodityId
|
||||
and s.validForDate <= :asOfDate and s.observedAt <= :asOf
|
||||
order by s.weekStart desc
|
||||
""")
|
||||
List<MarketSignal> findTrailing(@Param("districtId") Long districtId,
|
||||
@Param("commodityId") Long commodityId,
|
||||
@Param("asOfDate") LocalDate asOfDate,
|
||||
@Param("asOf") LocalDateTime asOf);
|
||||
|
||||
/** Freshness: the newest observation we hold for a district, whatever the commodity. */
|
||||
Optional<MarketSignal> findFirstByDistrictIdOrderByValidForDateDesc(Long districtId);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.lincfusion.udyamlinc.ingestion;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.WeekFields;
|
||||
|
||||
/**
|
||||
* Everything upstream reports daily; everything downstream reads weekly. One
|
||||
* definition of "week" for both signal tables, so a mandi price and a rainfall
|
||||
* figure for the same period actually line up.
|
||||
*/
|
||||
public final class Weeks {
|
||||
|
||||
private Weeks() {}
|
||||
|
||||
/** Monday of the ISO week the date falls in. */
|
||||
public static LocalDate startOf(LocalDate date) {
|
||||
return date.with(DayOfWeek.MONDAY);
|
||||
}
|
||||
|
||||
/** ISO week number, 1–53. */
|
||||
public static int isoWeek(LocalDate date) {
|
||||
return date.get(WeekFields.ISO.weekOfWeekBasedYear());
|
||||
}
|
||||
|
||||
/** ISO week-based year — not always the calendar year at a year boundary. */
|
||||
public static int isoYear(LocalDate date) {
|
||||
return date.get(WeekFields.ISO.weekBasedYear());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.lincfusion.udyamlinc.ingestion.client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** One day of the reanalysis series for a district centroid. */
|
||||
public record DailyWeather(LocalDate date, Double rainfallMm, Double tmaxC, Double tminC) {
|
||||
|
||||
public boolean usable() {
|
||||
return date != null && (rainfallMm != null || tmaxC != null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.lincfusion.udyamlinc.ingestion.client;
|
||||
|
||||
import com.lincfusion.udyamlinc.ingestion.ApiLog;
|
||||
import com.lincfusion.udyamlinc.ingestion.ApiLogRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Agmarknet mandi prices via the data.gov.in Open Government Data API.
|
||||
*
|
||||
* <p>Two things about this feed drive the design. Its field names change casing
|
||||
* between resource versions ({@code arrival_date} vs {@code Arrival_Date}), so
|
||||
* every read goes through a case-insensitive lookup rather than a bound DTO. And
|
||||
* it answers with HTTP 200 and an empty {@code records} array when a filter
|
||||
* matches nothing, so "no data" and "wrong filter" look identical — which is why
|
||||
* the caller records the record count per call.
|
||||
*
|
||||
* <p>Never called from a request thread.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class DataGovClient {
|
||||
|
||||
public static final String SOURCE = "AGMARKNET";
|
||||
|
||||
/** Agmarknet publishes dd/MM/yyyy; some mirrors publish ISO. Both are accepted. */
|
||||
private static final DateTimeFormatter DMY = DateTimeFormatter.ofPattern("dd/MM/yyyy");
|
||||
|
||||
private final RestClient http;
|
||||
private final ApiLogRepository apiLogRepository;
|
||||
|
||||
@Value("${udyam.ingestion.datagov.base-url:https://api.data.gov.in/resource}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${udyam.ingestion.datagov.resource-id:9ef84268-d588-465a-a308-a864a43d0070}")
|
||||
private String resourceId;
|
||||
|
||||
@Value("${udyam.ingestion.datagov.api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${udyam.ingestion.datagov.page-limit:1000}")
|
||||
private int pageLimit;
|
||||
|
||||
// Filter field names, because they must match the resource's own field names.
|
||||
@Value("${udyam.ingestion.datagov.field.state:state}")
|
||||
private String fieldState;
|
||||
|
||||
@Value("${udyam.ingestion.datagov.field.district:district}")
|
||||
private String fieldDistrict;
|
||||
|
||||
@Value("${udyam.ingestion.datagov.field.commodity:commodity}")
|
||||
private String fieldCommodity;
|
||||
|
||||
public DataGovClient(@Qualifier("externalFeedClient") RestClient http, ApiLogRepository apiLogRepository) {
|
||||
this.http = http;
|
||||
this.apiLogRepository = apiLogRepository;
|
||||
}
|
||||
|
||||
/** False when no key is configured — ingestion logs and skips rather than calling and failing. */
|
||||
public boolean configured() {
|
||||
return apiKey != null && !apiKey.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily arrivals for one district and commodity.
|
||||
*
|
||||
* <p>Returns {@code null} when the call itself failed and an empty list when
|
||||
* the feed answered with no rows. The distinction matters: the first is an
|
||||
* outage to report, the second is a filter that matched nothing. Either way
|
||||
* the run continues — a feed being down degrades tomorrow's freshness, it
|
||||
* does not abort ingestion.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<MandiRecord> fetchPrices(String state, String district, String commoditySourceCode) {
|
||||
if (!configured()) return null;
|
||||
|
||||
URI uri = UriComponentsBuilder.fromUriString(baseUrl + "/" + resourceId)
|
||||
.queryParam("api-key", apiKey)
|
||||
.queryParam("format", "json")
|
||||
.queryParam("limit", pageLimit)
|
||||
.queryParam("filters[" + fieldState + "]", state)
|
||||
.queryParam("filters[" + fieldDistrict + "]", district)
|
||||
.queryParam("filters[" + fieldCommodity + "]", commoditySourceCode)
|
||||
.build()
|
||||
.encode()
|
||||
.toUri();
|
||||
|
||||
long started = System.currentTimeMillis();
|
||||
try {
|
||||
Map<String, Object> body = http.get().uri(uri).retrieve().body(Map.class);
|
||||
List<Map<String, Object>> records = body == null
|
||||
? List.of()
|
||||
: (List<Map<String, Object>>) body.getOrDefault("records", List.of());
|
||||
|
||||
List<MandiRecord> parsed = new ArrayList<>(records.size());
|
||||
for (Map<String, Object> row : records) {
|
||||
MandiRecord rec = parse(row);
|
||||
if (rec.usable()) parsed.add(rec);
|
||||
}
|
||||
|
||||
logCall(uri, 200, started, records.size(), true, null);
|
||||
if (records.isEmpty()) {
|
||||
log.warn("data.gov.in returned no records for {}/{} — check the filter field names", district, commoditySourceCode);
|
||||
}
|
||||
return parsed;
|
||||
|
||||
} catch (Exception e) {
|
||||
logCall(uri, null, started, null, false, e.getMessage());
|
||||
log.warn("data.gov.in call failed for {}/{}: {}", district, commoditySourceCode, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── parsing ─────────────────────────────────────────────
|
||||
|
||||
private MandiRecord parse(Map<String, Object> row) {
|
||||
return new MandiRecord(
|
||||
str(row, "state"),
|
||||
str(row, "district"),
|
||||
str(row, "market"),
|
||||
str(row, "commodity"),
|
||||
str(row, "variety"),
|
||||
date(str(row, "arrival_date", "arrivaldate", "date")),
|
||||
num(str(row, "min_price", "minprice")),
|
||||
num(str(row, "max_price", "maxprice")),
|
||||
num(str(row, "modal_price", "modalprice")));
|
||||
}
|
||||
|
||||
/** Case- and underscore-insensitive lookup, because the feed's casing is not stable. */
|
||||
private String str(Map<String, Object> row, String... keys) {
|
||||
for (String key : keys) {
|
||||
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||
if (normalise(e.getKey()).equals(normalise(key))) {
|
||||
Object v = e.getValue();
|
||||
if (v == null) return null;
|
||||
String s = String.valueOf(v).trim();
|
||||
return s.isEmpty() || "NA".equalsIgnoreCase(s) ? null : s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalise(String s) {
|
||||
return s.replace("_", "").replace(" ", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private LocalDate date(String raw) {
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return LocalDate.parse(raw, DMY);
|
||||
} catch (Exception ignored) {
|
||||
try {
|
||||
return LocalDate.parse(raw);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal num(String raw) {
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return new BigDecimal(raw.replace(",", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void logCall(URI uri, Integer status, long started, Integer count, boolean ok, String error) {
|
||||
try {
|
||||
apiLogRepository.save(ApiLog.builder()
|
||||
.source(SOURCE)
|
||||
.method("GET")
|
||||
.url(redact(uri.toString()))
|
||||
.httpStatus(status)
|
||||
.durationMs(System.currentTimeMillis() - started)
|
||||
.recordCount(count)
|
||||
.success(ok)
|
||||
.error(error == null ? null : error.substring(0, Math.min(1000, error.length())))
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not write api_log: {}", e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/** The key never reaches the audit table. */
|
||||
private String redact(String url) {
|
||||
String redacted = url.replaceAll("api-key=[^&]*", "api-key=***");
|
||||
return redacted.length() > 1000 ? redacted.substring(0, 1000) : redacted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.lincfusion.udyamlinc.ingestion.client;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* One daily arrival row as Agmarknet publishes it, already normalised out of the
|
||||
* feed's inconsistent field casing.
|
||||
*/
|
||||
public record MandiRecord(
|
||||
String state,
|
||||
String district,
|
||||
String market,
|
||||
String commodity,
|
||||
String variety,
|
||||
LocalDate arrivalDate,
|
||||
BigDecimal minPrice,
|
||||
BigDecimal maxPrice,
|
||||
BigDecimal modalPrice) {
|
||||
|
||||
/** A row with no modal price and no date tells us nothing; the parser drops it. */
|
||||
public boolean usable() {
|
||||
return arrivalDate != null && modalPrice != null && modalPrice.signum() > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.lincfusion.udyamlinc.ingestion.client;
|
||||
|
||||
import com.lincfusion.udyamlinc.ingestion.ApiLog;
|
||||
import com.lincfusion.udyamlinc.ingestion.ApiLogRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Rainfall and temperature for a district centroid, from the Open-Meteo
|
||||
* reanalysis archive. No key, and decades of history in one request — which is
|
||||
* the only reason a 30-year normal is computable at all. IMD supplies the
|
||||
* district advisory text; it does not serve the series anomalies need.
|
||||
*
|
||||
* <p>The archive trails real time by a few days. {@code lag-days} keeps the
|
||||
* requested window inside what the archive actually holds, so a run does not
|
||||
* report failure for data that simply is not published yet.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class OpenMeteoClient {
|
||||
|
||||
public static final String SOURCE = "OPEN_METEO";
|
||||
|
||||
private final RestClient http;
|
||||
private final ApiLogRepository apiLogRepository;
|
||||
|
||||
@Value("${udyam.ingestion.climate.base-url:https://archive-api.open-meteo.com/v1/archive}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${udyam.ingestion.climate.timezone:Asia/Kolkata}")
|
||||
private String timezone;
|
||||
|
||||
public OpenMeteoClient(@Qualifier("externalFeedClient") RestClient http, ApiLogRepository apiLogRepository) {
|
||||
this.http = http;
|
||||
this.apiLogRepository = apiLogRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* The daily series for one grid cell. {@code null} when the call failed,
|
||||
* empty when the archive held nothing for the window.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<DailyWeather> fetchDaily(double latitude, double longitude, LocalDate from, LocalDate to) {
|
||||
URI uri = UriComponentsBuilder.fromUriString(baseUrl)
|
||||
.queryParam("latitude", latitude)
|
||||
.queryParam("longitude", longitude)
|
||||
.queryParam("start_date", from)
|
||||
.queryParam("end_date", to)
|
||||
.queryParam("daily", "precipitation_sum,temperature_2m_max,temperature_2m_min")
|
||||
.queryParam("timezone", timezone)
|
||||
.build()
|
||||
.encode()
|
||||
.toUri();
|
||||
|
||||
long started = System.currentTimeMillis();
|
||||
try {
|
||||
Map<String, Object> body = http.get().uri(uri).retrieve().body(Map.class);
|
||||
Map<String, Object> daily = body == null ? null : (Map<String, Object>) body.get("daily");
|
||||
if (daily == null) {
|
||||
logCall(uri, 200, started, 0, false, "no daily block in response");
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> dates = (List<String>) daily.getOrDefault("time", List.of());
|
||||
List<Number> rain = (List<Number>) daily.getOrDefault("precipitation_sum", List.of());
|
||||
List<Number> tmax = (List<Number>) daily.getOrDefault("temperature_2m_max", List.of());
|
||||
List<Number> tmin = (List<Number>) daily.getOrDefault("temperature_2m_min", List.of());
|
||||
|
||||
List<DailyWeather> out = new ArrayList<>(dates.size());
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
DailyWeather day = new DailyWeather(
|
||||
LocalDate.parse(dates.get(i)), at(rain, i), at(tmax, i), at(tmin, i));
|
||||
if (day.usable()) out.add(day);
|
||||
}
|
||||
|
||||
logCall(uri, 200, started, out.size(), true, null);
|
||||
return out;
|
||||
|
||||
} catch (Exception e) {
|
||||
logCall(uri, null, started, null, false, e.getMessage());
|
||||
log.warn("Open-Meteo call failed for {},{} {}..{}: {}", latitude, longitude, from, to, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Double at(List<Number> series, int i) {
|
||||
if (series == null || i >= series.size() || series.get(i) == null) return null;
|
||||
return series.get(i).doubleValue();
|
||||
}
|
||||
|
||||
private void logCall(URI uri, Integer status, long started, Integer count, boolean ok, String error) {
|
||||
try {
|
||||
String url = uri.toString();
|
||||
apiLogRepository.save(ApiLog.builder()
|
||||
.source(SOURCE)
|
||||
.method("GET")
|
||||
.url(url.length() > 1000 ? url.substring(0, 1000) : url)
|
||||
.httpStatus(status)
|
||||
.durationMs(System.currentTimeMillis() - started)
|
||||
.recordCount(count)
|
||||
.success(ok)
|
||||
.error(error == null ? null : error.substring(0, Math.min(1000, error.length())))
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not write api_log: {}", e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* A tradeable commodity tracked in a price feed. {@code sourceCode} is the name
|
||||
* the upstream feed uses, which rarely matches ours — Agmarknet says "Maize",
|
||||
* NECC says "Egg" with no commodity field at all.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "commodity", indexes = @Index(name = "ix_commodity_code", columnList = "code", unique = true))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Commodity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 32)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String name;
|
||||
|
||||
@Column(length = 64)
|
||||
private String nameHi;
|
||||
|
||||
/** AGMARKNET | NECC | DAIRY_FEDERATION | MANUAL */
|
||||
@Column(nullable = false, length = 32)
|
||||
private String source;
|
||||
|
||||
/** The identifier this commodity carries in the upstream feed. */
|
||||
@Column(length = 64)
|
||||
private String sourceCode;
|
||||
|
||||
/** QUINTAL | KG | HUNDRED | LITRE */
|
||||
@Column(length = 16)
|
||||
private String unit;
|
||||
|
||||
private Boolean active;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (active == null) active = Boolean.TRUE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface CommodityRepository extends JpaRepository<Commodity, Long> {
|
||||
|
||||
Optional<Commodity> findByCode(String code);
|
||||
|
||||
List<Commodity> findBySourceAndActiveTrue(String source);
|
||||
|
||||
List<Commodity> findByActiveTrue();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* The join key for every external signal. Mandi prices and climate series are
|
||||
* pulled per district, never per enterprise — that is what keeps ingestion to
|
||||
* roughly forty rows a day instead of one call per enterprise.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "district", indexes = @Index(name = "ix_district_code", columnList = "code", unique = true))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class District {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** SEHORE, VIDISHA… */
|
||||
@Column(nullable = false, unique = true, length = 32)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String name;
|
||||
|
||||
@Column(length = 64)
|
||||
private String nameHi;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String state;
|
||||
|
||||
/** District centroid — used to pick the climate grid cell. */
|
||||
private Double latitude;
|
||||
|
||||
private Double longitude;
|
||||
|
||||
private Boolean active;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (active == null) active = Boolean.TRUE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface DistrictRepository extends JpaRepository<District, Long> {
|
||||
|
||||
Optional<District> findByCode(String code);
|
||||
|
||||
Optional<District> findByNameIgnoreCase(String name);
|
||||
|
||||
List<District> findByActiveTrue();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/** A mandi. Enterprises are attributed to the reporting market in their district. */
|
||||
@Entity
|
||||
@Table(name = "market", indexes = {
|
||||
@Index(name = "ix_market_code", columnList = "code", unique = true),
|
||||
@Index(name = "ix_market_district", columnList = "districtId"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Market {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 48)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 96)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long districtId;
|
||||
|
||||
/** The market name as Agmarknet reports it, for the ingestion filter. */
|
||||
@Column(length = 96)
|
||||
private String sourceName;
|
||||
|
||||
/** Preferred reporting market for its district when several report. */
|
||||
private Boolean primaryMarket;
|
||||
|
||||
private Boolean active;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (active == null) active = Boolean.TRUE;
|
||||
if (primaryMarket == null) primaryMarket = Boolean.FALSE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface MarketRepository extends JpaRepository<Market, Long> {
|
||||
|
||||
Optional<Market> findByCode(String code);
|
||||
|
||||
List<Market> findByDistrictId(Long districtId);
|
||||
|
||||
Optional<Market> findFirstByDistrictIdAndPrimaryMarketTrue(Long districtId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/** Console RBAC. Permissions are a comma-separated set of MODULE:ACTION keys. */
|
||||
@Entity
|
||||
@Table(name = "role", indexes = @Index(name = "ix_role_code", columnList = "code", unique = true))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Role {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** FIELD_OFFICER | SUPERVISOR | OWNER */
|
||||
@Column(nullable = false, unique = true, length = 32)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String name;
|
||||
|
||||
/** e.g. "PORTFOLIO:VIEW,ALERT:ACT,VISIT:LOG" */
|
||||
@Column(length = 600)
|
||||
private String permissions;
|
||||
|
||||
/** System roles cannot be deleted from the console. */
|
||||
private Boolean systemRole;
|
||||
|
||||
private Boolean active;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (active == null) active = Boolean.TRUE;
|
||||
if (systemRole == null) systemRole = Boolean.FALSE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RoleRepository extends JpaRepository<Role, Long> {
|
||||
|
||||
Optional<Role> findByCode(String code);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* A sector risk pack — the weights that make the model trade-aware rather than
|
||||
* generic. Feed indices matter for poultry; fodder and yield for dairy; buyer
|
||||
* concentration for handicrafts.
|
||||
*
|
||||
* <p>Weights live here rather than in code so tuning a pack needs no deploy.
|
||||
* They are basis points and must sum to 10000.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sector", indexes = @Index(name = "ix_sector_code", columnList = "code", unique = true))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Sector {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** DAIRY | POULTRY | FOOD_PROC | HANDICRAFTS | RURAL_RETAIL */
|
||||
@Column(nullable = false, unique = true, length = 32)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String label;
|
||||
|
||||
@Column(length = 64)
|
||||
private String labelHi;
|
||||
|
||||
/** Short form for table columns, e.g. "Food proc." */
|
||||
@Column(length = 32)
|
||||
private String shortLabel;
|
||||
|
||||
private Integer wLiquidity;
|
||||
|
||||
private Integer wBehavioural;
|
||||
|
||||
private Integer wTrendVolatility;
|
||||
|
||||
private Integer wObligationLoad;
|
||||
|
||||
private Integer wExternalShock;
|
||||
|
||||
private Boolean active;
|
||||
|
||||
/** True when the five weights sum to 10000 basis points. */
|
||||
public boolean weightsBalanced() {
|
||||
return nz(wLiquidity) + nz(wBehavioural) + nz(wTrendVolatility) + nz(wObligationLoad) + nz(wExternalShock)
|
||||
== 10_000;
|
||||
}
|
||||
|
||||
private int nz(Integer v) {
|
||||
return v == null ? 0 : v;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (active == null) active = Boolean.TRUE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* Which commodities a sector buys and which it sells.
|
||||
*
|
||||
* <p>The INPUT / OUTPUT distinction is not cosmetic — it is what makes
|
||||
* {@code margin_squeeze} computable. Poultry buys maize and soya and sells eggs;
|
||||
* a feed rise with a flat egg rate is a squeeze, and a feed rise matched by an
|
||||
* egg rise is not.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sector_commodity", indexes = {
|
||||
@Index(name = "ix_sc_sector", columnList = "sectorId"),
|
||||
@Index(name = "ix_sc_pair", columnList = "sectorId,commodityId", unique = true),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SectorCommodity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long sectorId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long commodityId;
|
||||
|
||||
/** INPUT (a cost) | OUTPUT (a revenue). */
|
||||
@Column(nullable = false, length = 8)
|
||||
private String role;
|
||||
|
||||
/** Relative importance within its role, basis points. */
|
||||
private Integer weight;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface SectorCommodityRepository extends JpaRepository<SectorCommodity, Long> {
|
||||
|
||||
List<SectorCommodity> findBySectorId(Long sectorId);
|
||||
|
||||
List<SectorCommodity> findBySectorIdAndRole(Long sectorId, String role);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lincfusion.udyamlinc.master;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SectorRepository extends JpaRepository<Sector, Long> {
|
||||
|
||||
Optional<Sector> findByCode(String code);
|
||||
|
||||
List<Sector> findByActiveTrue();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.lincfusion.udyamlinc.officer;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A playbook intervention and its outcome.
|
||||
*
|
||||
* <p>The outcome is the feedback edge of the monitoring loop — it is what the
|
||||
* next retraining cycle learns from. {@code officerOverride} records the cases
|
||||
* where the officer disagreed with the model, which is the most valuable
|
||||
* training signal in the table.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "field_visit", indexes = {
|
||||
@Index(name = "ix_visit_visit_id", columnList = "visitId", unique = true),
|
||||
@Index(name = "ix_visit_enterprise", columnList = "enterpriseId"),
|
||||
@Index(name = "ix_visit_block", columnList = "block"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class FieldVisit {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 24)
|
||||
private String visitId;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
/** Denormalised for the console's block filter. */
|
||||
@Column(length = 128)
|
||||
private String enterpriseName;
|
||||
|
||||
@Column(length = 64)
|
||||
private String block;
|
||||
|
||||
/** Playbook code, e.g. PB-01. */
|
||||
@Column(length = 16)
|
||||
private String actionCode;
|
||||
|
||||
@Column(length = 200)
|
||||
private String action;
|
||||
|
||||
@Column(length = 200)
|
||||
private String actionHi;
|
||||
|
||||
/** The alert that prompted this visit, if any. */
|
||||
private Long alertId;
|
||||
|
||||
/** 1 = act first. */
|
||||
private Integer priority;
|
||||
|
||||
private LocalDate dueDate;
|
||||
|
||||
/** PLANNED | DONE | OVERDUE */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String state;
|
||||
|
||||
@Column(length = 600)
|
||||
private String outcome;
|
||||
|
||||
private Boolean officerOverride;
|
||||
|
||||
@Column(length = 64)
|
||||
private String officer;
|
||||
|
||||
private LocalDateTime loggedAt;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
if (state == null) state = "PLANNED";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.lincfusion.udyamlinc.officer;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FieldVisitRepository extends JpaRepository<FieldVisit, Long> {
|
||||
|
||||
Optional<FieldVisit> findByVisitId(String visitId);
|
||||
|
||||
List<FieldVisit> findByEnterpriseIdOrderByDueDateAsc(String enterpriseId);
|
||||
|
||||
List<FieldVisit> findByBlockOrderByPriorityAscDueDateAsc(String block);
|
||||
|
||||
List<FieldVisit> findAllByOrderByPriorityAscDueDateAsc();
|
||||
|
||||
long countByStateAndPriority(String state, Integer priority);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.lincfusion.udyamlinc.officer;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ApiResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.AlertActionRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.ScheduleVisitRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.VisitOutcomeRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.*;
|
||||
import com.lincfusion.udyamlinc.risk.RiskAlert;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The field officer console API. Every route here is gated on SCOPE_OFFICER by
|
||||
* {@code SecurityConfig}, so an enterprise token can never reach it.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/officer")
|
||||
@RequiredArgsConstructor
|
||||
public class OfficerController {
|
||||
|
||||
private final OfficerService officerService;
|
||||
|
||||
@GetMapping("/portfolio")
|
||||
public ApiResponse<PortfolioResponse> portfolio(
|
||||
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block,
|
||||
@RequestParam(required = false) String month) {
|
||||
return ApiResponse.ok(officerService.portfolio(block, month));
|
||||
}
|
||||
|
||||
@GetMapping("/enterprises")
|
||||
public ApiResponse<List<EnterpriseRow>> enterprises(
|
||||
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block) {
|
||||
return ApiResponse.ok(officerService.enterprises(block));
|
||||
}
|
||||
|
||||
@GetMapping("/enterprises/{enterpriseId}")
|
||||
public ApiResponse<EnterpriseDetailResponse> detail(@PathVariable String enterpriseId) {
|
||||
return ApiResponse.ok(officerService.detail(enterpriseId));
|
||||
}
|
||||
|
||||
@GetMapping("/alerts")
|
||||
public ApiResponse<List<RiskAlert>> alerts(
|
||||
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block,
|
||||
@RequestParam(required = false) String state) {
|
||||
return ApiResponse.ok(officerService.alerts(block, state));
|
||||
}
|
||||
|
||||
@PutMapping("/alerts/{alertId}")
|
||||
public ApiResponse<RiskAlert> disposition(
|
||||
@PathVariable String alertId,
|
||||
@Valid @RequestBody AlertActionRequest request,
|
||||
@AuthenticationPrincipal String actor) {
|
||||
return ApiResponse.ok("Alert updated", officerService.disposition(alertId, request.state(), request.note(), actor));
|
||||
}
|
||||
|
||||
@GetMapping("/visits")
|
||||
public ApiResponse<List<FieldVisit>> visits(
|
||||
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block) {
|
||||
return ApiResponse.ok(officerService.visits(block));
|
||||
}
|
||||
|
||||
@PostMapping("/visits")
|
||||
public ApiResponse<FieldVisit> schedule(
|
||||
@Valid @RequestBody ScheduleVisitRequest request,
|
||||
@AuthenticationPrincipal String officer) {
|
||||
return ApiResponse.ok("Visit scheduled", officerService.schedule(request, officer));
|
||||
}
|
||||
|
||||
@PutMapping("/visits/{visitId}/outcome")
|
||||
public ApiResponse<FieldVisit> logOutcome(
|
||||
@PathVariable String visitId,
|
||||
@Valid @RequestBody VisitOutcomeRequest request) {
|
||||
return ApiResponse.ok("Outcome logged",
|
||||
officerService.logOutcome(visitId, request.outcome(), request.officerOverride()));
|
||||
}
|
||||
|
||||
@GetMapping("/playbook")
|
||||
public ApiResponse<List<PlaybookActionResponse>> playbook() {
|
||||
return ApiResponse.ok(PlaybookCatalog.asResponses());
|
||||
}
|
||||
|
||||
@GetMapping("/sectors")
|
||||
public ApiResponse<List<SectorRollupResponse>> sectors(
|
||||
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block) {
|
||||
return ApiResponse.ok(officerService.sectorRollup(block));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.lincfusion.udyamlinc.officer;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.ScheduleVisitRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseDetailResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseRow;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.PortfolioResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.SectorRollupResponse;
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
|
||||
import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository;
|
||||
import com.lincfusion.udyamlinc.master.District;
|
||||
import com.lincfusion.udyamlinc.master.DistrictRepository;
|
||||
import com.lincfusion.udyamlinc.master.Sector;
|
||||
import com.lincfusion.udyamlinc.master.SectorRepository;
|
||||
import com.lincfusion.udyamlinc.risk.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Read side of the field officer console, plus the two writes an officer makes:
|
||||
* dispositioning an alert and scheduling or closing a visit.
|
||||
*
|
||||
* <p>Everything served here is read from stored scoring runs — this service does
|
||||
* not score. Scoring belongs to the risk engine, which writes the rows this
|
||||
* service reads back.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OfficerService {
|
||||
|
||||
/** Sentinel used by the console when no block filter is applied. */
|
||||
public static final String ALL_BLOCKS = "ALL";
|
||||
|
||||
private final EnterpriseRepository enterpriseRepository;
|
||||
private final LedgerEntryRepository ledgerRepository;
|
||||
private final SectorRepository sectorRepository;
|
||||
private final DistrictRepository districtRepository;
|
||||
private final StressScoreRepository scoreRepository;
|
||||
private final ReasonCodeRepository reasonRepository;
|
||||
private final ForecastPointRepository pointRepository;
|
||||
private final RiskAlertRepository alertRepository;
|
||||
private final FieldVisitRepository visitRepository;
|
||||
|
||||
public PortfolioResponse portfolio(String block, String month) {
|
||||
List<Enterprise> rows = enterprisesIn(block);
|
||||
long red = countBand(rows, "RED");
|
||||
long watch = countBand(rows, "WATCH");
|
||||
long standard = rows.size() - red - watch;
|
||||
|
||||
return new PortfolioResponse(
|
||||
block, month, rows.size(), 0, red, 0, watch, 0, standard, 0,
|
||||
minutesSinceLastScore(rows), toRows(rows));
|
||||
}
|
||||
|
||||
public List<EnterpriseRow> enterprises(String block) {
|
||||
return toRows(enterprisesIn(block));
|
||||
}
|
||||
|
||||
public EnterpriseDetailResponse detail(String enterpriseId) {
|
||||
Enterprise enterprise = requireEnterprise(enterpriseId);
|
||||
StressScore score = scoreRepository.findFirstByEnterpriseIdOrderByComputedAtDesc(enterpriseId).orElse(null);
|
||||
|
||||
return new EnterpriseDetailResponse(
|
||||
enterprise,
|
||||
sectorCode(enterprise),
|
||||
districtName(enterprise),
|
||||
score,
|
||||
reasonRepository.findByEnterpriseIdOrderByRankAsc(enterpriseId),
|
||||
pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId),
|
||||
alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId),
|
||||
visitRepository.findByEnterpriseIdOrderByDueDateAsc(enterpriseId),
|
||||
ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream().limit(60).toList());
|
||||
}
|
||||
|
||||
public List<RiskAlert> alerts(String block, String state) {
|
||||
List<RiskAlert> all = state == null || state.isBlank() || "ALL".equalsIgnoreCase(state)
|
||||
? alertRepository.findAllByOrderByScoreDesc()
|
||||
: alertRepository.findByStateOrderByScoreDesc(state.toUpperCase());
|
||||
if (ALL_BLOCKS.equalsIgnoreCase(block)) return all;
|
||||
|
||||
// One lookup for the whole page rather than a query per alert.
|
||||
Map<String, String> blockOf = enterpriseRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(Enterprise::getEnterpriseId, Enterprise::getBlock, (a, b) -> a));
|
||||
return all.stream().filter(a -> block.equalsIgnoreCase(blockOf.get(a.getEnterpriseId()))).toList();
|
||||
}
|
||||
|
||||
/** Acknowledge / escalate / snooze / close. Every change records who and when. */
|
||||
@Transactional
|
||||
public RiskAlert disposition(String alertId, String state, String note, String actor) {
|
||||
RiskAlert alert = alertRepository.findByAlertId(alertId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No alert " + alertId));
|
||||
String next = state.toUpperCase();
|
||||
if (!List.of("OPEN", "ACKNOWLEDGED", "ESCALATED", "SNOOZED", "CLOSED").contains(next)) {
|
||||
throw new IllegalArgumentException("Unknown alert state: " + state);
|
||||
}
|
||||
alert.setState(next);
|
||||
alert.setDispositionNote(note);
|
||||
alert.setDispositionedBy(actor);
|
||||
alert.setDispositionedAt(LocalDateTime.now());
|
||||
return alertRepository.save(alert);
|
||||
}
|
||||
|
||||
public List<FieldVisit> visits(String block) {
|
||||
return ALL_BLOCKS.equalsIgnoreCase(block)
|
||||
? visitRepository.findAllByOrderByPriorityAscDueDateAsc()
|
||||
: visitRepository.findByBlockOrderByPriorityAscDueDateAsc(block);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FieldVisit schedule(ScheduleVisitRequest request, String officer) {
|
||||
Enterprise enterprise = requireEnterprise(request.enterpriseId());
|
||||
PlaybookCatalog.Action action = PlaybookCatalog.byCode(request.actionCode());
|
||||
|
||||
FieldVisit visit = FieldVisit.builder()
|
||||
.visitId("FV-" + (9000 + visitRepository.count()))
|
||||
.enterpriseId(enterprise.getEnterpriseId())
|
||||
.enterpriseName(enterprise.getName())
|
||||
.block(enterprise.getBlock())
|
||||
.actionCode(action.code())
|
||||
.action(action.label())
|
||||
.actionHi(action.labelHi())
|
||||
.priority("RED".equals(enterprise.getBand()) ? 1 : 2)
|
||||
.dueDate(request.dueDate() == null ? LocalDate.now().plusDays(3) : LocalDate.parse(request.dueDate()))
|
||||
.state("PLANNED")
|
||||
.officer(officer)
|
||||
.build();
|
||||
return visitRepository.save(visit);
|
||||
}
|
||||
|
||||
/** Closing a visit is the feedback edge of the monitoring loop. */
|
||||
@Transactional
|
||||
public FieldVisit logOutcome(String visitId, String outcome, Boolean override) {
|
||||
FieldVisit visit = visitRepository.findByVisitId(visitId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No visit " + visitId));
|
||||
visit.setState("DONE");
|
||||
visit.setOutcome(outcome);
|
||||
visit.setOfficerOverride(Boolean.TRUE.equals(override));
|
||||
visit.setLoggedAt(LocalDateTime.now());
|
||||
return visitRepository.save(visit);
|
||||
}
|
||||
|
||||
/** Band mix and average score for each sector pack. */
|
||||
public List<SectorRollupResponse> sectorRollup(String block) {
|
||||
List<Enterprise> rows = enterprisesIn(block);
|
||||
return sectorRepository.findByActiveTrue().stream()
|
||||
.map(sector -> {
|
||||
List<Enterprise> mine = rows.stream()
|
||||
.filter(e -> sector.getId().equals(e.getSectorId()))
|
||||
.toList();
|
||||
long red = countBand(mine, "RED");
|
||||
long watch = countBand(mine, "WATCH");
|
||||
int avg = mine.isEmpty() ? 0
|
||||
: (int) Math.round(mine.stream().mapToInt(e -> nz(e.getScore())).average().orElse(0));
|
||||
return new SectorRollupResponse(
|
||||
sector.getCode(), sector.getLabel(), mine.size(), red, watch,
|
||||
mine.size() - red - watch, avg);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────
|
||||
|
||||
private List<Enterprise> enterprisesIn(String block) {
|
||||
return block == null || block.isBlank() || ALL_BLOCKS.equalsIgnoreCase(block)
|
||||
? enterpriseRepository.findAllByOrderByScoreDesc()
|
||||
: enterpriseRepository.findByBlockOrderByScoreDesc(block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten enterprises for the console table, resolving sector and district
|
||||
* codes once for the whole page rather than per row.
|
||||
*/
|
||||
private List<EnterpriseRow> toRows(List<Enterprise> rows) {
|
||||
Map<Long, Sector> sectors = sectorRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(Sector::getId, Function.identity()));
|
||||
Map<Long, District> districts = districtRepository.findAll().stream()
|
||||
.collect(Collectors.toMap(District::getId, Function.identity()));
|
||||
|
||||
// One query for every score, then grouped — not one query per enterprise.
|
||||
Map<String, List<StressScore>> history = scoreRepository.findAll().stream()
|
||||
.collect(Collectors.groupingBy(StressScore::getEnterpriseId));
|
||||
|
||||
return rows.stream()
|
||||
.map(e -> new EnterpriseRow(
|
||||
e.getId(),
|
||||
e.getEnterpriseId(),
|
||||
e.getName(),
|
||||
e.getNameHi(),
|
||||
sectors.containsKey(e.getSectorId()) ? sectors.get(e.getSectorId()).getCode() : null,
|
||||
e.getBlock(),
|
||||
districts.containsKey(e.getDistrictId()) ? districts.get(e.getDistrictId()).getName() : null,
|
||||
e.getOwnerCount(),
|
||||
e.getVintageMonths(),
|
||||
e.getLoanOutstanding(),
|
||||
e.getMonthlyInstalment(),
|
||||
e.getScore(),
|
||||
e.getBand(),
|
||||
trendOf(history.get(e.getEnterpriseId()), e.getScore()),
|
||||
e.getLastScoredAt() == null ? null : e.getLastScoredAt().toString()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Last six scores, oldest first, padded with the current score when short. */
|
||||
private List<Integer> trendOf(List<StressScore> scores, Integer current) {
|
||||
if (scores == null || scores.isEmpty()) {
|
||||
return current == null ? List.of() : List.of(current);
|
||||
}
|
||||
List<Integer> tail = scores.stream()
|
||||
.sorted((a, b) -> a.getComputedAt().compareTo(b.getComputedAt()))
|
||||
.map(StressScore::getScore)
|
||||
.toList();
|
||||
return tail.size() <= 6 ? tail : tail.subList(tail.size() - 6, tail.size());
|
||||
}
|
||||
|
||||
private int minutesSinceLastScore(List<Enterprise> rows) {
|
||||
return rows.stream()
|
||||
.map(Enterprise::getLastScoredAt)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.max(LocalDateTime::compareTo)
|
||||
.map(t -> (int) java.time.Duration.between(t, LocalDateTime.now()).toMinutes())
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
private long countBand(List<Enterprise> rows, String band) {
|
||||
return rows.stream().filter(e -> band.equals(e.getBand())).count();
|
||||
}
|
||||
|
||||
private String sectorCode(Enterprise e) {
|
||||
return sectorRepository.findById(e.getSectorId()).map(Sector::getCode).orElse(null);
|
||||
}
|
||||
|
||||
private String districtName(Enterprise e) {
|
||||
return districtRepository.findById(e.getDistrictId()).map(District::getName).orElse(null);
|
||||
}
|
||||
|
||||
private Enterprise requireEnterprise(String enterpriseId) {
|
||||
return enterpriseRepository.findByEnterpriseId(enterpriseId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No enterprise " + enterpriseId));
|
||||
}
|
||||
|
||||
private int nz(Integer v) {
|
||||
return v == null ? 0 : v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.lincfusion.udyamlinc.officer;
|
||||
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.PlaybookActionResponse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The ranked intervention playbook. Fixed rather than table-driven: the set is
|
||||
* small, every entry maps to a band, and the officer picks from what the model
|
||||
* recommends instead of typing free text.
|
||||
*/
|
||||
public final class PlaybookCatalog {
|
||||
|
||||
private PlaybookCatalog() {
|
||||
}
|
||||
|
||||
public record Action(String code, String label, String labelHi, String appliesTo, String detail) {
|
||||
}
|
||||
|
||||
public static final List<Action> ACTIONS = List.of(
|
||||
new Action("PB-01", "Restructure instalment to the earning season",
|
||||
"किस्त को कमाई के मौसम से मिलाएँ", "RED",
|
||||
"Move the due date behind the sector cash peak; no principal change."),
|
||||
new Action("PB-02", "Sanction a short working-capital top-up",
|
||||
"थोड़ी कार्यशील पूँजी और दें", "RED",
|
||||
"Bridge the projected P10 gap for two months against stock."),
|
||||
new Action("PB-03", "Field visit — verify stock and receivables",
|
||||
"मौके पर जाकर स्टॉक और उधारी जाँचें", "RED",
|
||||
"Confirm the ledger against physical stock before any action."),
|
||||
new Action("PB-04", "Input-cost counselling with the sector pack",
|
||||
"लागत घटाने की सलाह दें", "WATCH",
|
||||
"Bulk feed or raw purchase timing against the mandi price curve."),
|
||||
new Action("PB-05", "Enrol for daily ledger reminders",
|
||||
"रोज़ हिसाब दर्ज करने की याद दिलाएँ", "WATCH",
|
||||
"Ledger silence is the strongest early behavioural signal."),
|
||||
new Action("PB-06", "Diversify buyer concentration",
|
||||
"एक ही खरीदार पर निर्भरता घटाएँ", "WATCH",
|
||||
"Introduce to the block procurement group."),
|
||||
new Action("PB-07", "Routine monitoring — no action",
|
||||
"सिर्फ़ निगरानी, कोई कार्रवाई नहीं", "STANDARD",
|
||||
"Re-score next cycle."));
|
||||
|
||||
public static Action byCode(String code) {
|
||||
return ACTIONS.stream()
|
||||
.filter(a -> a.code().equalsIgnoreCase(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown playbook action: " + code));
|
||||
}
|
||||
|
||||
public static List<Action> forBand(String band) {
|
||||
return ACTIONS.stream().filter(a -> a.appliesTo().equalsIgnoreCase(band)).toList();
|
||||
}
|
||||
|
||||
public static List<PlaybookActionResponse> asResponses() {
|
||||
return ACTIONS.stream()
|
||||
.map(a -> new PlaybookActionResponse(a.code(), a.label(), a.labelHi(), a.appliesTo(), a.detail()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ApiResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.ForecastResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/forecast")
|
||||
@RequiredArgsConstructor
|
||||
public class ForecastController {
|
||||
|
||||
private final ForecastService forecastService;
|
||||
private final ScoringRunRepository runRepository;
|
||||
|
||||
@GetMapping("/{enterpriseId}")
|
||||
public ApiResponse<ForecastResponse> forecast(@PathVariable String enterpriseId) {
|
||||
return ApiResponse.ok(forecastService.forecast(enterpriseId));
|
||||
}
|
||||
|
||||
/** Recent scoring runs — the model prediction log, for supervisory review. */
|
||||
@GetMapping("/runs")
|
||||
public ApiResponse<List<ScoringRun>> runs() {
|
||||
return ApiResponse.ok(runRepository.findTop20ByOrderByRunAtDesc());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* One month of the quantile forecast. Where {@code p10 < obligation}, the
|
||||
* downside case fails to cover what is due and the enterprise escalates — that
|
||||
* single comparison is the product's entire trigger.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "forecast_point", indexes = {
|
||||
@Index(name = "ix_point_run", columnList = "runId"),
|
||||
@Index(name = "ix_point_enterprise", columnList = "enterpriseId"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ForecastPoint {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long runId;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
/** 1…6 — months ahead of the run's as-of date. */
|
||||
@Column(nullable = false)
|
||||
private Integer horizon;
|
||||
|
||||
/** '2026-10' */
|
||||
@Column(nullable = false, length = 7)
|
||||
private String month;
|
||||
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal p10;
|
||||
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal p50;
|
||||
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal p90;
|
||||
|
||||
/** Instalment plus fixed costs falling due that month. */
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal obligation;
|
||||
|
||||
/** True when p10 falls short of the obligation. Stored so queries stay simple. */
|
||||
private Boolean breach;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ForecastPointRepository extends JpaRepository<ForecastPoint, Long> {
|
||||
|
||||
List<ForecastPoint> findByRunIdOrderByHorizonAsc(Long runId);
|
||||
|
||||
List<ForecastPoint> findByEnterpriseIdOrderByHorizonAsc(String enterpriseId);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.ForecastResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Reads back the stored quantile path and derives the two facts the UI needs:
|
||||
* the first month the downside case fails to cover obligations, and the size of
|
||||
* that gap.
|
||||
*
|
||||
* <p>The path itself is produced by the risk engine or the model; this service
|
||||
* neither fits nor predicts anything.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ForecastService {
|
||||
|
||||
private final ScoringRunRepository runRepository;
|
||||
private final ForecastPointRepository pointRepository;
|
||||
|
||||
@Value("${udyam.forecast.horizon-months:6}")
|
||||
private int horizonMonths;
|
||||
|
||||
public ForecastResponse forecast(String enterpriseId) {
|
||||
ScoringRun run = runRepository.findFirstByEnterpriseIdOrderByRunAtDesc(enterpriseId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No scoring run for " + enterpriseId));
|
||||
|
||||
List<ForecastPoint> points = pointRepository.findByRunIdOrderByHorizonAsc(run.getId());
|
||||
ForecastPoint breach = firstBreach(points);
|
||||
|
||||
return new ForecastResponse(
|
||||
enterpriseId,
|
||||
run.getModelVersion(),
|
||||
run.getEngine(),
|
||||
run.getHorizonMonths() == null ? horizonMonths : run.getHorizonMonths(),
|
||||
breach == null ? null : breach.getMonth(),
|
||||
breach == null ? BigDecimal.ZERO : breach.getObligation().subtract(breach.getP10()),
|
||||
points);
|
||||
}
|
||||
|
||||
/** First month where P10 dips below what is due — the escalation trigger. */
|
||||
public ForecastPoint firstBreach(List<ForecastPoint> points) {
|
||||
return points.stream()
|
||||
.filter(p -> p.getP10() != null && p.getObligation() != null)
|
||||
.filter(p -> p.getP10().compareTo(p.getObligation()) < 0)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* A SHAP driver rendered as a sentence in both languages. Stored rather than
|
||||
* generated at read time, so the exact wording an officer acted on is preserved
|
||||
* even after the sentence templates change.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "reason_code", indexes = {
|
||||
@Index(name = "ix_reason_run", columnList = "runId"),
|
||||
@Index(name = "ix_reason_enterprise", columnList = "enterpriseId"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ReasonCode {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long runId;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
/** liquidity | behavioural | trendVolatility | obligationLoad | externalShock */
|
||||
@Column(nullable = false, length = 32)
|
||||
private String driver;
|
||||
|
||||
@Column(length = 64)
|
||||
private String driverHi;
|
||||
|
||||
/** SHAP contribution, 0–100. */
|
||||
@Column(nullable = false)
|
||||
private Integer weight;
|
||||
|
||||
/** 1 = strongest driver. Only the top three are surfaced to a user. */
|
||||
@Column(name = "driver_rank")
|
||||
private Integer rank;
|
||||
|
||||
@Column(length = 400)
|
||||
private String sentenceEn;
|
||||
|
||||
@Column(length = 400)
|
||||
private String sentenceHi;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ReasonCodeRepository extends JpaRepository<ReasonCode, Long> {
|
||||
|
||||
List<ReasonCode> findByRunIdOrderByRankAsc(Long runId);
|
||||
|
||||
List<ReasonCode> findByEnterpriseIdOrderByRankAsc(String enterpriseId);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.features.FeatureCatalog;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Turns a driver into a sentence a person can act on, in both languages.
|
||||
*
|
||||
* <p>"externalShock: 26" is not a reason code. "Feed costs are 18 % above their
|
||||
* eight-week average while the egg rate is flat" is. The officer console shows
|
||||
* the English; the enterprise app shows the Hindi, and the Hindi is written for
|
||||
* an owner rather than translated from analyst English.
|
||||
*
|
||||
* <p>Sentences are rendered once and stored on {@link ReasonCode}, so the wording
|
||||
* an officer acted on survives a later change to these templates.
|
||||
*/
|
||||
public final class ReasonSentences {
|
||||
|
||||
private ReasonSentences() {}
|
||||
|
||||
private static final Map<String, String> FAMILY_HI = Map.of(
|
||||
RuleBasedScoringStrategy.LIQUIDITY, "नकदी",
|
||||
RuleBasedScoringStrategy.BEHAVIOURAL, "भुगतान व्यवहार",
|
||||
RuleBasedScoringStrategy.TREND_VOLATILITY, "बाज़ार में उतार-चढ़ाव",
|
||||
RuleBasedScoringStrategy.OBLIGATION_LOAD, "किस्त का बोझ",
|
||||
RuleBasedScoringStrategy.EXTERNAL_SHOCK, "बाहरी झटका");
|
||||
|
||||
private static final Map<String, String> FAMILY_EN = Map.of(
|
||||
RuleBasedScoringStrategy.LIQUIDITY, "Liquidity",
|
||||
RuleBasedScoringStrategy.BEHAVIOURAL, "Repayment behaviour",
|
||||
RuleBasedScoringStrategy.TREND_VOLATILITY, "Market volatility",
|
||||
RuleBasedScoringStrategy.OBLIGATION_LOAD, "Obligation load",
|
||||
RuleBasedScoringStrategy.EXTERNAL_SHOCK, "External shock");
|
||||
|
||||
public static String driverHi(String family) {
|
||||
return FAMILY_HI.getOrDefault(family, family);
|
||||
}
|
||||
|
||||
public static String driverEn(String family) {
|
||||
return FAMILY_EN.getOrDefault(family, family);
|
||||
}
|
||||
|
||||
/** English sentence for the officer console. */
|
||||
public static String en(ScoreOutcome.Driver driver) {
|
||||
if (driver.onPrior() || driver.featureCode() == null || driver.value() == null) {
|
||||
return FAMILY_EN.getOrDefault(driver.family(), driver.family())
|
||||
+ " could not be measured — no recent data, so the sector's seasonal prior was used";
|
||||
}
|
||||
double v = driver.value();
|
||||
return switch (driver.featureCode()) {
|
||||
case FeatureCatalog.CASH_FLOW_CHANGE -> "Net cash flow is " + pct(v) + " against its trailing weekly rate";
|
||||
case FeatureCatalog.INVENTORY_DAYS -> "Stock on hand covers " + days(v) + " of sales — cash is sitting in goods";
|
||||
case FeatureCatalog.EMI_DELAY -> "Instalment is " + days(v) + " past due";
|
||||
case FeatureCatalog.DAYS_SINCE_LAST_ENTRY -> "No ledger entry for " + days(v);
|
||||
case FeatureCatalog.PRICE_Z_SCORE -> "Sale price is " + fixed(Math.abs(v)) + " standard deviations "
|
||||
+ (v < 0 ? "below" : "above") + " its seasonal norm";
|
||||
case FeatureCatalog.DSCR_P10 -> "In the worst forecast month the downside case covers "
|
||||
+ fixed(v) + "× what is due" + (v < 1.0 ? " — short of the obligation" : "");
|
||||
case FeatureCatalog.EMI_TO_INFLOW -> "The instalment takes " + pct(v) + " of monthly inflow";
|
||||
case FeatureCatalog.MARGIN_SQUEEZE -> "Input costs have risen " + pct(v) + " more than output prices";
|
||||
case FeatureCatalog.RAINFALL_ANOMALY -> "Season rainfall is " + pct(v / 100) + " against the 30-year normal";
|
||||
case FeatureCatalog.HEAT_ANOMALY -> "Weekly maximum temperature is " + fixed(v) + " °C above normal";
|
||||
default -> FAMILY_EN.getOrDefault(driver.family(), driver.family()) + " is elevated";
|
||||
};
|
||||
}
|
||||
|
||||
/** Hindi sentence for the enterprise app — written for an owner, not translated. */
|
||||
public static String hi(ScoreOutcome.Driver driver) {
|
||||
if (driver.onPrior() || driver.featureCode() == null || driver.value() == null) {
|
||||
return FAMILY_HI.getOrDefault(driver.family(), driver.family())
|
||||
+ " का पता नहीं चल पाया — नया आंकड़ा नहीं मिला";
|
||||
}
|
||||
double v = driver.value();
|
||||
return switch (driver.featureCode()) {
|
||||
case FeatureCatalog.CASH_FLOW_CHANGE -> v < 0
|
||||
? "पिछले हफ़्तों के मुक़ाबले आमदनी " + pctHi(Math.abs(v)) + " कम है"
|
||||
: "पिछले हफ़्तों के मुक़ाबले आमदनी " + pctHi(v) + " ज़्यादा है";
|
||||
case FeatureCatalog.INVENTORY_DAYS -> "इतना स्टॉक पड़ा है कि " + daysHi(v) + " चल जाए — पैसा माल में फँसा है";
|
||||
case FeatureCatalog.EMI_DELAY -> "किस्त " + daysHi(v) + " से बकाया है";
|
||||
case FeatureCatalog.DAYS_SINCE_LAST_ENTRY -> daysHi(v) + " से कोई हिसाब दर्ज नहीं हुआ";
|
||||
case FeatureCatalog.PRICE_Z_SCORE -> v < 0
|
||||
? "आपके माल का भाव इस मौसम के औसत से नीचे है"
|
||||
: "आपके माल का भाव इस मौसम के औसत से ऊपर है";
|
||||
case FeatureCatalog.DSCR_P10 -> v < 1.0
|
||||
? "आने वाले महीनों में किस्त चुकाने लायक पैसा कम पड़ सकता है"
|
||||
: "फ़िलहाल किस्त चुकाने लायक पैसा रहने का अनुमान है";
|
||||
case FeatureCatalog.EMI_TO_INFLOW -> "महीने की आमदनी का " + pctHi(v) + " किस्त में चला जाता है";
|
||||
case FeatureCatalog.MARGIN_SQUEEZE -> "लागत बढ़ी है पर बिक्री का भाव उतना नहीं बढ़ा";
|
||||
case FeatureCatalog.RAINFALL_ANOMALY -> v < 0
|
||||
? "इस मौसम में बारिश सामान्य से कम रही है"
|
||||
: "इस मौसम में बारिश सामान्य से ज़्यादा रही है";
|
||||
case FeatureCatalog.HEAT_ANOMALY -> "गर्मी सामान्य से " + fixed(v) + " डिग्री ज़्यादा चल रही है";
|
||||
default -> FAMILY_HI.getOrDefault(driver.family(), driver.family()) + " का असर दिख रहा है";
|
||||
};
|
||||
}
|
||||
|
||||
// ── formatting ──────────────────────────────────────────
|
||||
|
||||
private static String pct(double ratio) {
|
||||
return String.format(Locale.ENGLISH, "%.0f%%", ratio * 100);
|
||||
}
|
||||
|
||||
private static String pctHi(double ratio) {
|
||||
return String.format(Locale.ENGLISH, "%.0f%%", ratio * 100);
|
||||
}
|
||||
|
||||
private static String days(double value) {
|
||||
return String.format(Locale.ENGLISH, "%.0f days", value);
|
||||
}
|
||||
|
||||
private static String daysHi(double value) {
|
||||
return String.format(Locale.ENGLISH, "%.0f दिन", value);
|
||||
}
|
||||
|
||||
private static String fixed(double value) {
|
||||
return String.format(Locale.ENGLISH, "%.2f", value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* An escalation, raised either by the model or by a deterministic trip-wire.
|
||||
*
|
||||
* <p>Both {@code ruleId} and {@code featureSnapshotId} are recorded so any alert
|
||||
* can be reproduced exactly: the rule that fired, and the inputs it fired on. A
|
||||
* trip-wire alert therefore stands on its own evidence even after the model has
|
||||
* been retrained.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "risk_alert", indexes = {
|
||||
@Index(name = "ix_alert_alert_id", columnList = "alertId", unique = true),
|
||||
@Index(name = "ix_alert_enterprise", columnList = "enterpriseId"),
|
||||
@Index(name = "ix_alert_state", columnList = "state"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class RiskAlert {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 24)
|
||||
private String alertId;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
private Long runId;
|
||||
|
||||
@Column(nullable = false, length = 16)
|
||||
private String band;
|
||||
|
||||
private Integer score;
|
||||
|
||||
/** MODEL | TRIPWIRE */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String origin;
|
||||
|
||||
/** Set when origin=TRIPWIRE — the rule that fired. */
|
||||
private Long ruleId;
|
||||
|
||||
/** The wide feature row the decision was made on. */
|
||||
private Long featureSnapshotId;
|
||||
|
||||
@Column(length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(length = 200)
|
||||
private String titleHi;
|
||||
|
||||
@Column(length = 400)
|
||||
private String reasonEn;
|
||||
|
||||
@Column(length = 400)
|
||||
private String reasonHi;
|
||||
|
||||
/** Projected gap at P10 in the breach month. */
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal shortfall;
|
||||
|
||||
@Column(length = 7)
|
||||
private String breachMonth;
|
||||
|
||||
/** OPEN | ACKNOWLEDGED | ESCALATED | SNOOZED | CLOSED */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String state;
|
||||
|
||||
@Column(length = 64)
|
||||
private String dispositionedBy;
|
||||
|
||||
@Column(length = 400)
|
||||
private String dispositionNote;
|
||||
|
||||
private LocalDateTime dispositionedAt;
|
||||
|
||||
private LocalDateTime raisedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (raisedAt == null) raisedAt = LocalDateTime.now();
|
||||
if (state == null) state = "OPEN";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RiskAlertRepository extends JpaRepository<RiskAlert, Long> {
|
||||
|
||||
Optional<RiskAlert> findByAlertId(String alertId);
|
||||
|
||||
List<RiskAlert> findByEnterpriseIdOrderByRaisedAtDesc(String enterpriseId);
|
||||
|
||||
List<RiskAlert> findAllByOrderByScoreDesc();
|
||||
|
||||
List<RiskAlert> findByStateOrderByScoreDesc(String state);
|
||||
|
||||
long countByState(String state);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ApiResponse;
|
||||
import com.lincfusion.udyamlinc.rules.RuleOutcome;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Operating the engine: run it, and see why a rule did or did not fire.
|
||||
*
|
||||
* <p>Under {@code /admin}, so reads are open to any officer and triggering a run
|
||||
* is supervisor-only under the existing rule.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/scoring")
|
||||
@RequiredArgsConstructor
|
||||
public class RiskEngineController {
|
||||
|
||||
private final RiskEngineService riskEngine;
|
||||
private final ScoringRunRepository runRepository;
|
||||
|
||||
@GetMapping("/runs")
|
||||
public ApiResponse<List<ScoringRun>> runs(@RequestParam(defaultValue = "30") int limit) {
|
||||
return ApiResponse.ok(runRepository.findAll(
|
||||
PageRequest.of(0, Math.min(limit, 200),
|
||||
org.springframework.data.domain.Sort.by("runAt").descending()))
|
||||
.getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Every rule's outcome for one enterprise, without writing anything. A rule
|
||||
* that is not firing is usually starved of fresh data rather than badly
|
||||
* thresholded, and this is what shows which.
|
||||
*/
|
||||
@GetMapping("/explain/{enterpriseId}")
|
||||
public ApiResponse<List<RuleOutcome>> explain(
|
||||
@PathVariable String enterpriseId,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
return ApiResponse.ok(riskEngine.explain(enterpriseId, date == null ? LocalDate.now() : date));
|
||||
}
|
||||
|
||||
// ── triggers ────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/run")
|
||||
public ApiResponse<RiskEngineService.Result> runAll(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
return ApiResponse.ok("Scoring run complete",
|
||||
riskEngine.scoreAll(date == null ? LocalDate.now() : date));
|
||||
}
|
||||
|
||||
@PostMapping("/run/{enterpriseId}")
|
||||
public ApiResponse<RiskEngineService.Outcome> runOne(
|
||||
@PathVariable String enterpriseId,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
|
||||
return ApiResponse.ok("Rescored",
|
||||
riskEngine.scoreOne(enterpriseId, date == null ? LocalDate.now() : date));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
|
||||
import com.lincfusion.udyamlinc.features.*;
|
||||
import com.lincfusion.udyamlinc.master.Sector;
|
||||
import com.lincfusion.udyamlinc.master.SectorRepository;
|
||||
import com.lincfusion.udyamlinc.rules.RuleEvaluator;
|
||||
import com.lincfusion.udyamlinc.rules.RuleOutcome;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* One scoring pass: features in, score and alerts out.
|
||||
*
|
||||
* <p>The order matters. Features are computed first so the run scores what it
|
||||
* just derived rather than yesterday's snapshot. The engine then produces a
|
||||
* score, and the trip-wires run <b>separately</b> against the same feature rows —
|
||||
* never against the score. That independence is the point: a trip-wire alert
|
||||
* stays valid and explainable after the model has been retrained, because it
|
||||
* never depended on the model in the first place.
|
||||
*
|
||||
* <p>A fired trip-wire may escalate the band but never rewrites the score. The
|
||||
* two answer different questions — "how stressed is this enterprise" and "has a
|
||||
* specific, agreed line been crossed" — and collapsing them would lose the
|
||||
* second.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class RiskEngineService {
|
||||
|
||||
private static final DateTimeFormatter ALERT_STAMP = DateTimeFormatter.ofPattern("MMdd");
|
||||
|
||||
private final EnterpriseRepository enterpriseRepository;
|
||||
private final SectorRepository sectorRepository;
|
||||
private final FeatureEngineeringService featureService;
|
||||
private final FeatureValueRepository featureValueRepository;
|
||||
private final RuleEvaluator ruleEvaluator;
|
||||
private final ScoringRunRepository runRepository;
|
||||
private final StressScoreRepository scoreRepository;
|
||||
private final ReasonCodeRepository reasonRepository;
|
||||
private final RiskAlertRepository alertRepository;
|
||||
private final ScoringStrategy strategy;
|
||||
|
||||
@Value("${udyam.forecast.horizon-months:6}")
|
||||
private int horizon;
|
||||
|
||||
public RiskEngineService(EnterpriseRepository enterpriseRepository,
|
||||
SectorRepository sectorRepository,
|
||||
FeatureEngineeringService featureService,
|
||||
FeatureValueRepository featureValueRepository,
|
||||
RuleEvaluator ruleEvaluator,
|
||||
ScoringRunRepository runRepository,
|
||||
StressScoreRepository scoreRepository,
|
||||
ReasonCodeRepository reasonRepository,
|
||||
RiskAlertRepository alertRepository,
|
||||
List<ScoringStrategy> strategies,
|
||||
@Value("${udyam.risk.engine:RULE}") String engineName) {
|
||||
|
||||
this.enterpriseRepository = enterpriseRepository;
|
||||
this.sectorRepository = sectorRepository;
|
||||
this.featureService = featureService;
|
||||
this.featureValueRepository = featureValueRepository;
|
||||
this.ruleEvaluator = ruleEvaluator;
|
||||
this.runRepository = runRepository;
|
||||
this.scoreRepository = scoreRepository;
|
||||
this.reasonRepository = reasonRepository;
|
||||
this.alertRepository = alertRepository;
|
||||
this.strategy = strategies.stream()
|
||||
.filter(s -> s.engine().equalsIgnoreCase(engineName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"No ScoringStrategy for udyam.risk.engine=" + engineName
|
||||
+ "; available: " + strategies.stream().map(ScoringStrategy::engine).toList()));
|
||||
log.info("Risk engine bound to {} ({})", this.strategy.engine(), this.strategy.modelVersion());
|
||||
}
|
||||
|
||||
// ── batch ───────────────────────────────────────────────
|
||||
|
||||
/** The whole book. Consent-revoked enterprises are not scored at all. */
|
||||
public Result scoreAll(LocalDate asOfDate) {
|
||||
long started = System.currentTimeMillis();
|
||||
ScoringRun run = runRepository.save(ScoringRun.builder()
|
||||
.engine(strategy.engine())
|
||||
.modelVersion(strategy.modelVersion())
|
||||
.source("SERVER")
|
||||
.asOfDate(asOfDate)
|
||||
.horizonMonths(horizon)
|
||||
.status("RUNNING")
|
||||
.build());
|
||||
|
||||
int scored = 0, skipped = 0, alerts = 0, tripwires = 0;
|
||||
for (Enterprise enterprise : enterpriseRepository.findAll()) {
|
||||
if (Boolean.FALSE.equals(enterprise.getConsentActive())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
Outcome outcome = scoreOne(enterprise, run, asOfDate);
|
||||
scored++;
|
||||
alerts += outcome.alertsRaised();
|
||||
tripwires += outcome.firedRules().size();
|
||||
}
|
||||
|
||||
run.setEnterpriseCount(scored);
|
||||
run.setStatus("COMPLETED");
|
||||
run.setDurationMs(System.currentTimeMillis() - started);
|
||||
run.setNote("%d scored · %d skipped for consent · %d trip-wires fired · %d alerts raised"
|
||||
.formatted(scored, skipped, tripwires, alerts));
|
||||
runRepository.save(run);
|
||||
|
||||
log.info("Scoring run {} — {}", run.getId(), run.getNote());
|
||||
return new Result(run.getId(), scored, skipped, tripwires, alerts, run.getNote());
|
||||
}
|
||||
|
||||
/** One enterprise, on its own run, for a rescore after a visit or a data fix. */
|
||||
public Outcome scoreOne(String enterpriseId, LocalDate asOfDate) {
|
||||
Enterprise enterprise = enterpriseRepository.findByEnterpriseId(enterpriseId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No enterprise " + enterpriseId));
|
||||
|
||||
ScoringRun run = runRepository.save(ScoringRun.builder()
|
||||
.enterpriseId(enterpriseId)
|
||||
.engine(strategy.engine())
|
||||
.modelVersion(strategy.modelVersion())
|
||||
.source("SERVER")
|
||||
.asOfDate(asOfDate)
|
||||
.horizonMonths(horizon)
|
||||
.enterpriseCount(1)
|
||||
.status("COMPLETED")
|
||||
.build());
|
||||
|
||||
return scoreOne(enterprise, run, asOfDate);
|
||||
}
|
||||
|
||||
// ── the pass ────────────────────────────────────────────
|
||||
|
||||
private Outcome scoreOne(Enterprise enterprise, ScoringRun run, LocalDate asOfDate) {
|
||||
LocalDateTime observedAsOf = LocalDateTime.now();
|
||||
|
||||
FeatureSnapshot snapshot = featureService.computeFor(enterprise, asOfDate, observedAsOf, run.getId());
|
||||
Map<String, FeatureValue> features = new HashMap<>();
|
||||
featureValueRepository.findByEnterpriseIdAndValidForDateOrderByFeatureCode(
|
||||
enterprise.getEnterpriseId(), asOfDate)
|
||||
.forEach(value -> features.put(value.getFeatureCode(), value));
|
||||
|
||||
Sector sector = sectorRepository.findById(enterprise.getSectorId()).orElse(null);
|
||||
String sectorCode = sector == null ? null : sector.getCode();
|
||||
|
||||
ScoreOutcome scored = strategy.score(enterprise, sector, features);
|
||||
|
||||
// Trip-wires read the same features, never the score.
|
||||
List<RuleOutcome> outcomes = ruleEvaluator.evaluateAll(sectorCode, features);
|
||||
List<RuleOutcome> fired = outcomes.stream().filter(RuleOutcome::fired).toList();
|
||||
String band = escalate(scored.band(), fired);
|
||||
|
||||
persistScore(enterprise, run, snapshot, scored, band);
|
||||
persistReasons(enterprise, run, scored);
|
||||
int raised = persistAlerts(enterprise, run, snapshot, scored, band, fired, asOfDate);
|
||||
|
||||
enterprise.setScore(scored.score());
|
||||
enterprise.setBand(band);
|
||||
enterprise.setLastScoredAt(observedAsOf);
|
||||
enterpriseRepository.save(enterprise);
|
||||
|
||||
return new Outcome(enterprise.getEnterpriseId(), scored.score(), band, scored.confidence(),
|
||||
snapshot.getId(), scored, outcomes, fired, raised);
|
||||
}
|
||||
|
||||
/**
|
||||
* A RED trip-wire escalates whatever the score said; a WATCH one only lifts a
|
||||
* STANDARD. The score is left untouched — the band is a decision, the score is
|
||||
* a measurement, and an audit needs to see both.
|
||||
*/
|
||||
private String escalate(String band, List<RuleOutcome> fired) {
|
||||
boolean red = fired.stream().anyMatch(o -> "RED".equalsIgnoreCase(o.severity()));
|
||||
if (red) return "RED";
|
||||
boolean watch = fired.stream().anyMatch(o -> "WATCH".equalsIgnoreCase(o.severity()));
|
||||
if (watch && "STANDARD".equals(band)) return "WATCH";
|
||||
return band;
|
||||
}
|
||||
|
||||
private void persistScore(Enterprise enterprise, ScoringRun run, FeatureSnapshot snapshot,
|
||||
ScoreOutcome scored, String band) {
|
||||
scoreRepository.save(StressScore.builder()
|
||||
.runId(run.getId())
|
||||
.enterpriseId(enterprise.getEnterpriseId())
|
||||
.score(scored.score())
|
||||
.band(band)
|
||||
.engine(strategy.engine())
|
||||
.confidence(scored.confidence())
|
||||
.liquidity(scored.subScores().get(RuleBasedScoringStrategy.LIQUIDITY))
|
||||
.behavioural(scored.subScores().get(RuleBasedScoringStrategy.BEHAVIOURAL))
|
||||
.trendVolatility(scored.subScores().get(RuleBasedScoringStrategy.TREND_VOLATILITY))
|
||||
.obligationLoad(scored.subScores().get(RuleBasedScoringStrategy.OBLIGATION_LOAD))
|
||||
.externalShock(scored.subScores().get(RuleBasedScoringStrategy.EXTERNAL_SHOCK))
|
||||
.featureSnapshotId(snapshot.getId())
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Top three drivers, rendered once and stored, so the wording an officer acted on is preserved. */
|
||||
private void persistReasons(Enterprise enterprise, ScoringRun run, ScoreOutcome scored) {
|
||||
int rank = 1;
|
||||
List<ReasonCode> reasons = new ArrayList<>();
|
||||
for (ScoreOutcome.Driver driver : scored.drivers()) {
|
||||
if (rank > 3) break;
|
||||
reasons.add(ReasonCode.builder()
|
||||
.runId(run.getId())
|
||||
.enterpriseId(enterprise.getEnterpriseId())
|
||||
.driver(driver.family())
|
||||
.driverHi(ReasonSentences.driverHi(driver.family()))
|
||||
.weight(driver.weight())
|
||||
.rank(rank++)
|
||||
.sentenceEn(ReasonSentences.en(driver))
|
||||
.sentenceHi(ReasonSentences.hi(driver))
|
||||
.build());
|
||||
}
|
||||
reasonRepository.saveAll(reasons);
|
||||
}
|
||||
|
||||
/**
|
||||
* One alert per fired rule per enterprise per day, plus a model-origin alert
|
||||
* when the score alone reaches RED without any rule firing — a high score must
|
||||
* never sit silent.
|
||||
*
|
||||
* <p>Both carry the rule id and the snapshot id, so any alert can be
|
||||
* reproduced from the inputs it was raised on.
|
||||
*/
|
||||
private int persistAlerts(Enterprise enterprise, ScoringRun run, FeatureSnapshot snapshot,
|
||||
ScoreOutcome scored, String band, List<RuleOutcome> fired, LocalDate asOfDate) {
|
||||
int raised = 0;
|
||||
String stamp = asOfDate.format(ALERT_STAMP);
|
||||
String shortId = enterprise.getEnterpriseId().replace("MSME-", "");
|
||||
|
||||
for (RuleOutcome outcome : fired) {
|
||||
String alertId = "AL-" + shortId + "-" + outcome.ruleCode().replace("-", "") + "-" + stamp;
|
||||
if (alertRepository.findByAlertId(alertId).isPresent()) continue;
|
||||
|
||||
alertRepository.save(RiskAlert.builder()
|
||||
.alertId(alertId)
|
||||
.enterpriseId(enterprise.getEnterpriseId())
|
||||
.runId(run.getId())
|
||||
.band(outcome.severity())
|
||||
.score(scored.score())
|
||||
.origin("TRIPWIRE")
|
||||
.ruleId(outcome.ruleId())
|
||||
.featureSnapshotId(snapshot.getId())
|
||||
.title(outcome.ruleCode() + " · " + outcome.detail())
|
||||
.reasonEn(scored.drivers().isEmpty() ? null : ReasonSentences.en(scored.drivers().get(0)))
|
||||
.reasonHi(scored.drivers().isEmpty() ? null : ReasonSentences.hi(scored.drivers().get(0)))
|
||||
.state("OPEN")
|
||||
.build());
|
||||
raised++;
|
||||
}
|
||||
|
||||
if ("RED".equals(band) && fired.isEmpty()) {
|
||||
String alertId = "AL-" + shortId + "-SCORE-" + stamp;
|
||||
if (alertRepository.findByAlertId(alertId).isEmpty()) {
|
||||
alertRepository.save(RiskAlert.builder()
|
||||
.alertId(alertId)
|
||||
.enterpriseId(enterprise.getEnterpriseId())
|
||||
.runId(run.getId())
|
||||
.band(band)
|
||||
.score(scored.score())
|
||||
.origin("MODEL")
|
||||
.featureSnapshotId(snapshot.getId())
|
||||
.title("Composite stress score " + scored.score())
|
||||
.reasonEn(scored.drivers().isEmpty() ? null : ReasonSentences.en(scored.drivers().get(0)))
|
||||
.reasonHi(scored.drivers().isEmpty() ? null : ReasonSentences.hi(scored.drivers().get(0)))
|
||||
.state("OPEN")
|
||||
.build());
|
||||
raised++;
|
||||
}
|
||||
}
|
||||
return raised;
|
||||
}
|
||||
|
||||
// ── dry run ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate the rules without writing anything. This is how a supervisor sees
|
||||
* why a rule is not firing — usually a stale or missing feature rather than a
|
||||
* threshold that is wrong.
|
||||
*/
|
||||
public List<RuleOutcome> explain(String enterpriseId, LocalDate asOfDate) {
|
||||
Enterprise enterprise = enterpriseRepository.findByEnterpriseId(enterpriseId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No enterprise " + enterpriseId));
|
||||
|
||||
Map<String, FeatureValue> features = new HashMap<>();
|
||||
featureValueRepository.findByEnterpriseIdAndValidForDateOrderByFeatureCode(enterpriseId, asOfDate)
|
||||
.forEach(value -> features.put(value.getFeatureCode(), value));
|
||||
|
||||
String sectorCode = sectorRepository.findById(enterprise.getSectorId())
|
||||
.map(Sector::getCode).orElse(null);
|
||||
return ruleEvaluator.evaluateAll(sectorCode, features);
|
||||
}
|
||||
|
||||
// ── results ─────────────────────────────────────────────
|
||||
|
||||
public record Outcome(String enterpriseId, int score, String band, int confidence,
|
||||
Long featureSnapshotId, ScoreOutcome scored,
|
||||
List<RuleOutcome> ruleOutcomes, List<RuleOutcome> firedRules, int alertsRaised) {}
|
||||
|
||||
public record Result(Long runId, int scored, int skippedForConsent,
|
||||
int tripWiresFired, int alertsRaised, String message) {}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.features.FeatureCatalog;
|
||||
import com.lincfusion.udyamlinc.features.FeatureValue;
|
||||
import com.lincfusion.udyamlinc.master.Sector;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* The engine that ships first: five family sub-scores, combined with the sector's
|
||||
* own weights.
|
||||
*
|
||||
* <p>Each family maps its features onto a 0–100 stress scale where higher is
|
||||
* worse, then the families are combined in basis points from the {@code sector}
|
||||
* row. Tuning a pack is a database edit. The mapping constants below are the
|
||||
* honest part to argue with — they are calibrated to the pilot's ranges, not
|
||||
* learned, and that is exactly what the LightGBM heads replace.
|
||||
*
|
||||
* <p>A family with no usable feature falls back to the sector prior rather than
|
||||
* scoring zero. Zero would read as "this enterprise is safe on this dimension",
|
||||
* when the truth is that we do not know — and the confidence drop is what makes
|
||||
* the difference visible.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class RuleBasedScoringStrategy implements ScoringStrategy {
|
||||
|
||||
public static final String LIQUIDITY = "liquidity";
|
||||
public static final String BEHAVIOURAL = "behavioural";
|
||||
public static final String TREND_VOLATILITY = "trendVolatility";
|
||||
public static final String OBLIGATION_LOAD = "obligationLoad";
|
||||
public static final String EXTERNAL_SHOCK = "externalShock";
|
||||
|
||||
@Value("${udyam.risk.model-version:rule-v1.0.0}")
|
||||
private String modelVersion;
|
||||
|
||||
@Value("${udyam.risk.red-threshold:65}")
|
||||
private int redThreshold;
|
||||
|
||||
@Value("${udyam.risk.watch-threshold:35}")
|
||||
private int watchThreshold;
|
||||
|
||||
/** Where a family lands when it has nothing to go on. Neutral, not innocent. */
|
||||
@Value("${udyam.risk.family-prior:40}")
|
||||
private int familyPrior;
|
||||
|
||||
/** A rule engine reports low confidence by design; this is its ceiling. */
|
||||
@Value("${udyam.risk.rule-confidence-ceiling:70}")
|
||||
private int confidenceCeiling;
|
||||
|
||||
@Override
|
||||
public String engine() {
|
||||
return "RULE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String modelVersion() {
|
||||
return modelVersion;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScoreOutcome score(Enterprise enterprise, Sector sector, Map<String, FeatureValue> features) {
|
||||
Map<String, Family> families = new LinkedHashMap<>();
|
||||
families.put(LIQUIDITY, liquidity(features));
|
||||
families.put(BEHAVIOURAL, behavioural(features));
|
||||
families.put(TREND_VOLATILITY, trendVolatility(features));
|
||||
families.put(OBLIGATION_LOAD, obligationLoad(features));
|
||||
families.put(EXTERNAL_SHOCK, externalShock(features));
|
||||
|
||||
Map<String, Integer> weights = weights(sector);
|
||||
int totalWeight = weights.values().stream().mapToInt(Integer::intValue).sum();
|
||||
if (totalWeight == 0) totalWeight = 10_000;
|
||||
|
||||
long weighted = 0;
|
||||
int onPrior = 0;
|
||||
List<ScoreOutcome.Driver> drivers = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<String, Family> entry : families.entrySet()) {
|
||||
Family family = entry.getValue();
|
||||
int weight = weights.getOrDefault(entry.getKey(), 0);
|
||||
weighted += (long) family.value() * weight;
|
||||
if (family.onPrior()) onPrior++;
|
||||
// Contribution as a 0–100 share of the composite, which is what a
|
||||
// driver bar in the console is showing.
|
||||
int contribution = (int) Math.round(family.value() * weight / (double) totalWeight);
|
||||
drivers.add(new ScoreOutcome.Driver(entry.getKey(), contribution,
|
||||
family.featureCode(), family.featureValue(), family.onPrior()));
|
||||
}
|
||||
|
||||
int score = clamp((int) Math.round(weighted / (double) totalWeight), 0, 100);
|
||||
drivers.sort(Comparator.comparingInt(ScoreOutcome.Driver::weight).reversed());
|
||||
|
||||
Map<String, Integer> subScores = new LinkedHashMap<>();
|
||||
families.forEach((name, family) -> subScores.put(name, family.value()));
|
||||
|
||||
return new ScoreOutcome(score, band(score), confidence(onPrior), subScores, drivers, onPrior);
|
||||
}
|
||||
|
||||
// ── families ────────────────────────────────────────────
|
||||
|
||||
/** Money coming in against money going out, and cash sitting in unsold stock. */
|
||||
private Family liquidity(Map<String, FeatureValue> features) {
|
||||
// A 40 % fall against the trailing rate is full stress on this axis.
|
||||
Signal cash = signal(features, FeatureCatalog.CASH_FLOW_CHANGE, v -> -v * 250);
|
||||
// Beyond about 55 days of stock, working capital is stuck in goods.
|
||||
Signal inventory = signal(features, FeatureCatalog.INVENTORY_DAYS, v -> (v - 15) * 2.5);
|
||||
return worst(cash, inventory);
|
||||
}
|
||||
|
||||
/** Repayment conduct, and the silence that usually precedes it. */
|
||||
private Family behavioural(Map<String, FeatureValue> features) {
|
||||
// 70 days past due is full stress.
|
||||
Signal dpd = signal(features, FeatureCatalog.EMI_DELAY, v -> v * 1.43);
|
||||
// A ledger quiet for a month is full stress; a week is nothing.
|
||||
Signal silence = signal(features, FeatureCatalog.DAYS_SINCE_LAST_ENTRY, v -> (v - 7) * 4);
|
||||
return worst(dpd, silence);
|
||||
}
|
||||
|
||||
/** How far out of its own seasonal norm the enterprise's market has moved. */
|
||||
private Family trendVolatility(Map<String, FeatureValue> features) {
|
||||
// Three standard deviations either way is full stress.
|
||||
return worst(signal(features, FeatureCatalog.PRICE_Z_SCORE, v -> Math.abs(v) * 33));
|
||||
}
|
||||
|
||||
/** Whether what is due can be covered — the product's central question. */
|
||||
private Family obligationLoad(Map<String, FeatureValue> features) {
|
||||
// DSCR of 1.5 is comfortable, 0.5 is full stress.
|
||||
Signal dscr = signal(features, FeatureCatalog.DSCR_P10, v -> (1.5 - v) * 100);
|
||||
// An instalment past 55 % of monthly inflow is full stress.
|
||||
Signal share = signal(features, FeatureCatalog.EMI_TO_INFLOW, v -> (v - 0.15) * 250);
|
||||
return worst(dscr, share);
|
||||
}
|
||||
|
||||
/** What the weather and the mandi did, none of which the enterprise controls. */
|
||||
private Family externalShock(Map<String, FeatureValue> features) {
|
||||
// A third of margin eaten by input costs is full stress.
|
||||
Signal squeeze = signal(features, FeatureCatalog.MARGIN_SQUEEZE, v -> v * 300);
|
||||
// A 60 % rainfall deficit is full stress; surplus does not score.
|
||||
Signal rain = signal(features, FeatureCatalog.RAINFALL_ANOMALY, v -> -v * 1.67);
|
||||
// Three degrees above the norm is full stress.
|
||||
Signal heat = signal(features, FeatureCatalog.HEAT_ANOMALY, v -> v * 33);
|
||||
return worst(squeeze, rain, heat);
|
||||
}
|
||||
|
||||
// ── mechanics ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The family takes its worst signal rather than their mean. Averaging lets a
|
||||
* calm dimension mask an acute one, and it is the acute one an officer needs
|
||||
* to be sent to.
|
||||
*/
|
||||
private Family worst(Signal... signals) {
|
||||
Signal chosen = null;
|
||||
for (Signal signal : signals) {
|
||||
if (signal == null) continue;
|
||||
if (chosen == null || signal.stress() > chosen.stress()) chosen = signal;
|
||||
}
|
||||
return chosen == null
|
||||
? new Family(clamp(familyPrior, 0, 100), null, null, true)
|
||||
: new Family(chosen.stress(), chosen.code(), chosen.raw(), false);
|
||||
}
|
||||
|
||||
private Signal signal(Map<String, FeatureValue> features, String code, java.util.function.DoubleUnaryOperator map) {
|
||||
FeatureValue value = features.get(code);
|
||||
if (value == null || value.getValue() == null) return null;
|
||||
double raw = value.getValue().doubleValue();
|
||||
return new Signal(code, raw, clamp((int) Math.round(map.applyAsDouble(raw)), 0, 100));
|
||||
}
|
||||
|
||||
private Map<String, Integer> weights(Sector sector) {
|
||||
Map<String, Integer> weights = new LinkedHashMap<>();
|
||||
if (sector == null) {
|
||||
// No pack configured: equal weight beats silently zero-weighting a family.
|
||||
FAMILIES.forEach(name -> weights.put(name, 2000));
|
||||
return weights;
|
||||
}
|
||||
weights.put(LIQUIDITY, nz(sector.getWLiquidity()));
|
||||
weights.put(BEHAVIOURAL, nz(sector.getWBehavioural()));
|
||||
weights.put(TREND_VOLATILITY, nz(sector.getWTrendVolatility()));
|
||||
weights.put(OBLIGATION_LOAD, nz(sector.getWObligationLoad()));
|
||||
weights.put(EXTERNAL_SHOCK, nz(sector.getWExternalShock()));
|
||||
return weights;
|
||||
}
|
||||
|
||||
/** Each family on a prior costs confidence — five of five means we know nothing. */
|
||||
private int confidence(int familiesOnPrior) {
|
||||
return clamp(confidenceCeiling - familiesOnPrior * 12, 10, confidenceCeiling);
|
||||
}
|
||||
|
||||
private String band(int score) {
|
||||
if (score >= redThreshold) return "RED";
|
||||
if (score >= watchThreshold) return "WATCH";
|
||||
return "STANDARD";
|
||||
}
|
||||
|
||||
private static final List<String> FAMILIES =
|
||||
List.of(LIQUIDITY, BEHAVIOURAL, TREND_VOLATILITY, OBLIGATION_LOAD, EXTERNAL_SHOCK);
|
||||
|
||||
private int nz(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
private int clamp(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private record Signal(String code, Double raw, int stress) {}
|
||||
|
||||
private record Family(int value, String featureCode, Double featureValue, boolean onPrior) {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* What an engine produced for one enterprise, before any of it is persisted.
|
||||
*
|
||||
* <p>Sub-scores and drivers travel with the composite so the number can always be
|
||||
* explained by the parts that made it, rather than reverse-engineered later.
|
||||
*/
|
||||
public record ScoreOutcome(
|
||||
int score,
|
||||
String band,
|
||||
int confidence,
|
||||
/** liquidity | behavioural | trendVolatility | obligationLoad | externalShock → 0–100 */
|
||||
Map<String, Integer> subScores,
|
||||
List<Driver> drivers,
|
||||
/** Families that had no usable feature and fell back to the sector prior. */
|
||||
int familiesOnPrior) {
|
||||
|
||||
/**
|
||||
* One family's contribution to the composite. In the rule engine this is the
|
||||
* family's weighted share; when the model lands it becomes the SHAP value,
|
||||
* and the rest of the pipeline does not change.
|
||||
*/
|
||||
public record Driver(String family, int weight, String featureCode, Double value, boolean onPrior) {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One scoring pass. Every score, forecast point, reason code and alert hangs off
|
||||
* a run, so an alert raised months ago always traces back to the exact engine
|
||||
* and inputs that produced it. This doubles as the model prediction log.
|
||||
*
|
||||
* <p>Generalised from the earlier {@code forecast_run}: a run may be produced by
|
||||
* the rule engine or by the model, and either on the server or on the phone.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "scoring_run", indexes = {
|
||||
@Index(name = "ix_run_enterprise", columnList = "enterpriseId"),
|
||||
@Index(name = "ix_run_as_of", columnList = "asOfDate"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ScoringRun {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** Null for a portfolio-wide batch; set for a single-enterprise rescore. */
|
||||
@Column(length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
/** RULE | MODEL | BLEND */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String engine;
|
||||
|
||||
@Column(nullable = false, length = 32)
|
||||
private String modelVersion;
|
||||
|
||||
/** SERVER | ON_DEVICE — edge inference reconciles against the server on sync. */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String source;
|
||||
|
||||
/**
|
||||
* The as-of date for point-in-time correctness: no feature observed after
|
||||
* this date may be read by this run.
|
||||
*/
|
||||
@Column(nullable = false)
|
||||
private LocalDate asOfDate;
|
||||
|
||||
private Integer horizonMonths;
|
||||
|
||||
private Integer enterpriseCount;
|
||||
|
||||
/** RUNNING | COMPLETED | FAILED */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String status;
|
||||
|
||||
private Long durationMs;
|
||||
|
||||
@Column(length = 400)
|
||||
private String note;
|
||||
|
||||
private LocalDateTime runAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (runAt == null) runAt = LocalDateTime.now();
|
||||
if (asOfDate == null) asOfDate = LocalDate.now();
|
||||
if (status == null) status = "COMPLETED";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ScoringRunRepository extends JpaRepository<ScoringRun, Long> {
|
||||
|
||||
Optional<ScoringRun> findFirstByEnterpriseIdOrderByRunAtDesc(String enterpriseId);
|
||||
|
||||
List<ScoringRun> findTop20ByOrderByRunAtDesc();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* The nightly scoring pass, deliberately an hour behind ingestion so it scores
|
||||
* the data that job just wrote rather than racing it.
|
||||
*
|
||||
* <p>Off by default for the same reason ingestion is: a demo boot should score
|
||||
* when asked, not on a timer.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ScoringScheduler {
|
||||
|
||||
private final RiskEngineService riskEngine;
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
@Value("${udyam.risk.scheduled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Scheduled(cron = "${udyam.risk.cron:0 0 3 * * *}", zone = "${udyam.ingestion.zone:Asia/Kolkata}")
|
||||
public void nightly() {
|
||||
if (!enabled) {
|
||||
log.debug("Nightly scoring skipped — udyam.risk.scheduled=false");
|
||||
return;
|
||||
}
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
log.warn("Scoring pass already running — this trigger is dropped, not queued");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
riskEngine.scoreAll(LocalDate.now());
|
||||
} catch (Exception e) {
|
||||
log.error("Nightly scoring failed", e);
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.features.FeatureValue;
|
||||
import com.lincfusion.udyamlinc.master.Sector;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The seam the model arrives through.
|
||||
*
|
||||
* <p>The engine is rule-based first — a sector-weighted combination of the five
|
||||
* families, with the weights in the {@code sector} table so tuning needs no
|
||||
* deploy. When the LightGBM heads exist they implement this interface and
|
||||
* replacing the engine is a configuration change, not a rewrite. Everything
|
||||
* downstream of here — scores, reason codes, alerts, the console — is written
|
||||
* against this contract rather than against either engine.
|
||||
*
|
||||
* <p>Implementations read features and nothing else. No implementation may reach
|
||||
* back into ingestion or the signal tables; that is what makes the model
|
||||
* retrainable in isolation.
|
||||
*/
|
||||
public interface ScoringStrategy {
|
||||
|
||||
/** RULE | MODEL | BLEND — recorded on every run, score and alert. */
|
||||
String engine();
|
||||
|
||||
/** Recorded on the run so a historic alert names the exact engine that raised it. */
|
||||
String modelVersion();
|
||||
|
||||
/**
|
||||
* @param features the enterprise's feature rows by catalogue code, including
|
||||
* the nulls — an engine must be able to see what it did not get
|
||||
*/
|
||||
ScoreOutcome score(Enterprise enterprise, Sector sector, Map<String, FeatureValue> features);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* The Composite Stress Score and the five independent risk families behind it.
|
||||
*
|
||||
* <p>Sub-scores are stored rather than recomputed, so a historic score can always
|
||||
* be explained with the numbers that actually produced it — even after the
|
||||
* sector weights have been retuned.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "stress_score", indexes = {
|
||||
@Index(name = "ix_score_enterprise", columnList = "enterpriseId"),
|
||||
@Index(name = "ix_score_run", columnList = "runId"),
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class StressScore {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long runId;
|
||||
|
||||
@Column(nullable = false, length = 24)
|
||||
private String enterpriseId;
|
||||
|
||||
/** 0–100, sector-weighted combination of the five families below. */
|
||||
@Column(nullable = false)
|
||||
private Integer score;
|
||||
|
||||
@Column(nullable = false, length = 16)
|
||||
private String band;
|
||||
|
||||
/** RULE | MODEL | BLEND — which engine produced this score. */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String engine;
|
||||
|
||||
/** 0–100. A rule engine reports low confidence by design. */
|
||||
private Integer confidence;
|
||||
|
||||
private Integer liquidity;
|
||||
|
||||
private Integer behavioural;
|
||||
|
||||
private Integer trendVolatility;
|
||||
|
||||
private Integer obligationLoad;
|
||||
|
||||
private Integer externalShock;
|
||||
|
||||
/** The wide feature row this score was computed from, for reproducibility. */
|
||||
private Long featureSnapshotId;
|
||||
|
||||
private LocalDateTime computedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (computedAt == null) computedAt = LocalDateTime.now();
|
||||
if (engine == null) engine = "RULE";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StressScoreRepository extends JpaRepository<StressScore, Long> {
|
||||
|
||||
Optional<StressScore> findFirstByEnterpriseIdOrderByComputedAtDesc(String enterpriseId);
|
||||
|
||||
List<StressScore> findByEnterpriseIdOrderByComputedAtDesc(String enterpriseId);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.lincfusion.udyamlinc.rules;
|
||||
|
||||
import com.lincfusion.udyamlinc.features.FeatureDefinition;
|
||||
import com.lincfusion.udyamlinc.features.FeatureDefinitionRepository;
|
||||
import com.lincfusion.udyamlinc.features.FeatureValue;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.expression.MapAccessor;
|
||||
import org.springframework.expression.Expression;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.expression.spel.support.SimpleEvaluationContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Runs the deterministic trip-wires against an enterprise's features.
|
||||
*
|
||||
* <p>Independent of the model on purpose. A trip-wire alert survives a retrain —
|
||||
* that is the entire point — so nothing here consults a score, only the feature
|
||||
* values and their freshness.
|
||||
*
|
||||
* <p>Two guards stand in front of every expression:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Missing</b> — a required feature with no value skips the rule. SpEL
|
||||
* comparing null against a number throws, and a rule that throws every
|
||||
* night looks identical to a rule that never matches.</li>
|
||||
* <li><b>Stale</b> — a required feature older than the tolerance skips the
|
||||
* rule. A rule firing on a three-week-old mandi price is worse than no
|
||||
* rule, so the tolerance is the stricter of the rule's own
|
||||
* {@code maxStalenessDays} and the catalogue's per-feature limit.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Values are handed to SpEL as doubles rather than {@code BigDecimal}. The
|
||||
* rules are written as plain arithmetic comparisons and this keeps their
|
||||
* semantics obvious; nothing here needs decimal exactness.
|
||||
*
|
||||
* <p>Expressions run in a {@code SimpleEvaluationContext} restricted to property
|
||||
* access. These strings are authored by business users through the admin API, so
|
||||
* method calls, type references and bean lookups are not on the table.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class RuleEvaluator {
|
||||
|
||||
private final TripWireRuleRepository ruleRepository;
|
||||
private final FeatureDefinitionRepository definitionRepository;
|
||||
|
||||
private final SpelExpressionParser parser = new SpelExpressionParser();
|
||||
|
||||
/** Keyed by rule code and version, so an edit invalidates its own entry. */
|
||||
private final Map<String, Expression> compiled = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Every active rule against one enterprise's feature rows, in catalogue order.
|
||||
* Returns an outcome for each — including the skips, which are the whole
|
||||
* reason this returns outcomes rather than a list of alerts.
|
||||
*/
|
||||
public List<RuleOutcome> evaluateAll(String sectorCode, Map<String, FeatureValue> features) {
|
||||
Map<String, FeatureDefinition> catalogue = new HashMap<>();
|
||||
definitionRepository.findByActiveTrue().forEach(d -> catalogue.put(d.getCode(), d));
|
||||
|
||||
List<RuleOutcome> outcomes = new ArrayList<>();
|
||||
for (TripWireRule rule : ruleRepository.findByActiveTrueOrderByCodeAsc()) {
|
||||
outcomes.add(evaluate(rule, sectorCode, features, catalogue));
|
||||
}
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
/** One rule. Package-visible so the dry-run endpoint can explain a single code. */
|
||||
public RuleOutcome evaluate(TripWireRule rule, String sectorCode,
|
||||
Map<String, FeatureValue> features,
|
||||
Map<String, FeatureDefinition> catalogue) {
|
||||
|
||||
if (rule.getSectorCode() != null && !rule.getSectorCode().equals(sectorCode)) {
|
||||
return RuleOutcome.of(rule, RuleOutcome.Status.SKIPPED_SECTOR, rule.getSectorCode());
|
||||
}
|
||||
|
||||
// ── guards ──
|
||||
for (String code : requiredFeatures(rule)) {
|
||||
FeatureValue value = features.get(code);
|
||||
if (value == null || value.getValue() == null) {
|
||||
String reason = value == null ? "not computed" : value.getMissingReason();
|
||||
return RuleOutcome.of(rule, RuleOutcome.Status.SKIPPED_MISSING, code + " (" + reason + ")");
|
||||
}
|
||||
Integer tolerance = tolerance(rule, catalogue.get(code));
|
||||
if (tolerance != null && value.getStalenessDays() != null && value.getStalenessDays() > tolerance) {
|
||||
return RuleOutcome.of(rule, RuleOutcome.Status.SKIPPED_STALE,
|
||||
code + " is " + value.getStalenessDays() + "d old, tolerance " + tolerance + "d");
|
||||
}
|
||||
}
|
||||
|
||||
// ── evaluation ──
|
||||
Map<String, Object> root = new HashMap<>();
|
||||
features.forEach((code, value) -> {
|
||||
if (value.getValue() != null) root.put(code, value.getValue().doubleValue());
|
||||
});
|
||||
|
||||
try {
|
||||
Expression expression = compiled.computeIfAbsent(
|
||||
rule.getCode() + ":" + rule.getVersion(), key -> parser.parseExpression(rule.getExpression()));
|
||||
Boolean result = expression.getValue(
|
||||
SimpleEvaluationContext.forPropertyAccessors(new MapAccessor()).build(),
|
||||
root, Boolean.class);
|
||||
return RuleOutcome.of(rule,
|
||||
Boolean.TRUE.equals(result) ? RuleOutcome.Status.FIRED : RuleOutcome.Status.NOT_FIRED,
|
||||
rule.getThresholdText());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Trip-wire {} failed to evaluate: {}", rule.getCode(), e.toString());
|
||||
return RuleOutcome.of(rule, RuleOutcome.Status.ERROR, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stricter of the two limits. The rule may be more cautious than the
|
||||
* catalogue, never less — a supervisor tightening a rule must not be
|
||||
* overridden by a generous default.
|
||||
*/
|
||||
private Integer tolerance(TripWireRule rule, FeatureDefinition definition) {
|
||||
Integer ruleLimit = rule.getMaxStalenessDays();
|
||||
Integer featureLimit = definition == null ? null : definition.getStalenessToleranceDays();
|
||||
if (ruleLimit == null) return featureLimit;
|
||||
if (featureLimit == null) return ruleLimit;
|
||||
return Math.min(ruleLimit, featureLimit);
|
||||
}
|
||||
|
||||
private List<String> requiredFeatures(TripWireRule rule) {
|
||||
if (rule.getRequiredFeatures() == null || rule.getRequiredFeatures().isBlank()) return List.of();
|
||||
return Arrays.stream(rule.getRequiredFeatures().split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Clears the compiled cache — called after a rule edit. */
|
||||
public void invalidate() {
|
||||
compiled.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.lincfusion.udyamlinc.rules;
|
||||
|
||||
/**
|
||||
* What one rule did against one snapshot.
|
||||
*
|
||||
* <p>A rule that did not fire and a rule that was not allowed to run are
|
||||
* different events, and the console must be able to tell them apart — "no alert"
|
||||
* because the enterprise is fine reads very differently from "no alert" because
|
||||
* the mandi feed has been down for a fortnight.
|
||||
*/
|
||||
public record RuleOutcome(
|
||||
String ruleCode,
|
||||
Long ruleId,
|
||||
Integer ruleVersion,
|
||||
String severity,
|
||||
Status status,
|
||||
/** The feature that caused a skip, or the expression's evaluated result. */
|
||||
String detail) {
|
||||
|
||||
public enum Status {
|
||||
/** The expression evaluated true. */
|
||||
FIRED,
|
||||
/** It ran and came back false. */
|
||||
NOT_FIRED,
|
||||
/** A required feature was older than the rule tolerates. */
|
||||
SKIPPED_STALE,
|
||||
/** A required feature had no value at all. */
|
||||
SKIPPED_MISSING,
|
||||
/** Sector-scoped rule, different sector. */
|
||||
SKIPPED_SECTOR,
|
||||
/** The expression itself is broken — a configuration fault, not a risk signal. */
|
||||
ERROR
|
||||
}
|
||||
|
||||
public boolean fired() {
|
||||
return status == Status.FIRED;
|
||||
}
|
||||
|
||||
public boolean evaluated() {
|
||||
return status == Status.FIRED || status == Status.NOT_FIRED;
|
||||
}
|
||||
|
||||
static RuleOutcome of(TripWireRule rule, Status status, String detail) {
|
||||
return new RuleOutcome(rule.getCode(), rule.getId(), rule.getVersion(),
|
||||
rule.getSeverity(), status, detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.lincfusion.udyamlinc.rules;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* A deterministic rule that fires independently of the model.
|
||||
*
|
||||
* <p>{@code expression} is SpEL evaluated against the enterprise's wide feature
|
||||
* snapshot, e.g. {@code emi_delay > 30 and cash_flow_change < -0.40}. Business
|
||||
* teams edit rows; nobody deploys. SpEL rather than a rules platform — a handful
|
||||
* of rules does not justify Drools.
|
||||
*
|
||||
* <p>{@code maxStalenessDays} is the safety catch: a rule must not fire on data
|
||||
* older than it tolerates, because a rule firing on a three-week-old mandi price
|
||||
* is worse than no rule at all.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "tripwire_rule", indexes = @Index(name = "ix_rule_code", columnList = "code", unique = true))
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class TripWireRule {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** TW-01… */
|
||||
@Column(nullable = false, unique = true, length = 16)
|
||||
private String code;
|
||||
|
||||
@Column(nullable = false, length = 160)
|
||||
private String label;
|
||||
|
||||
@Column(length = 160)
|
||||
private String labelHi;
|
||||
|
||||
/** SpEL over the feature snapshot. */
|
||||
@Column(nullable = false, length = 600)
|
||||
private String expression;
|
||||
|
||||
/** Human-readable form of the threshold, shown in the console. */
|
||||
@Column(length = 160)
|
||||
private String thresholdText;
|
||||
|
||||
/** RED | WATCH — the band this rule escalates to when it fires. */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String severity;
|
||||
|
||||
/** Null means the rule applies to every sector. */
|
||||
@Column(length = 32)
|
||||
private String sectorCode;
|
||||
|
||||
/** Features referenced by the expression, CSV — used for the staleness check. */
|
||||
@Column(length = 400)
|
||||
private String requiredFeatures;
|
||||
|
||||
private Integer maxStalenessDays;
|
||||
|
||||
@Column(length = 400)
|
||||
private String messageEn;
|
||||
|
||||
@Column(length = 400)
|
||||
private String messageHi;
|
||||
|
||||
private Boolean active;
|
||||
|
||||
/** Bumped on every edit so an old alert can name the rule version that fired. */
|
||||
private Integer version;
|
||||
|
||||
@Column(length = 64)
|
||||
private String updatedBy;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (active == null) active = Boolean.TRUE;
|
||||
if (version == null) version = 1;
|
||||
if (maxStalenessDays == null) maxStalenessDays = 14;
|
||||
if (updatedAt == null) updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lincfusion.udyamlinc.rules;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface TripWireRuleRepository extends JpaRepository<TripWireRule, Long> {
|
||||
|
||||
Optional<TripWireRule> findByCode(String code);
|
||||
|
||||
List<TripWireRule> findByActiveTrueOrderByCodeAsc();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.lincfusion.udyamlinc.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Reads the bearer token and grants two authorities: the role (ROLE_FIELD_OFFICER,
|
||||
* ROLE_OWNER…) and the scope (SCOPE_OFFICER / SCOPE_ENTERPRISE), which is what the
|
||||
* two API surfaces are separated on.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
if (jwtUtil.isValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
String subject = jwtUtil.subject(token);
|
||||
var authorities = List.of(
|
||||
new SimpleGrantedAuthority("ROLE_" + jwtUtil.role(token)),
|
||||
new SimpleGrantedAuthority("SCOPE_" + jwtUtil.scope(token)));
|
||||
var auth = new UsernamePasswordAuthenticationToken(subject, null, authorities);
|
||||
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
}
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.lincfusion.udyamlinc.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.security.Key;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Issues and reads the two token flavours: officer console and enterprise app. */
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
@Value("${jwt.secret}")
|
||||
private String secret;
|
||||
|
||||
@Value("${jwt.expiration:86400000}")
|
||||
private long expirationMs;
|
||||
|
||||
private Key signingKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes());
|
||||
}
|
||||
|
||||
/** Field officer session — scope=OFFICER, carries the role and the blocks in view. */
|
||||
public String generateOfficerToken(String username, String role, String blocks) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("realm", "UDYAM");
|
||||
claims.put("scope", "OFFICER");
|
||||
claims.put("role", role);
|
||||
claims.put("blocks", blocks);
|
||||
return build(claims, username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enterprise app session — scope=ENTERPRISE, pinned to one enterprise id so
|
||||
* the owner can only ever read their own record.
|
||||
*/
|
||||
public String generateEnterpriseToken(String enterpriseId, String sector) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("realm", "UDYAM");
|
||||
claims.put("scope", "ENTERPRISE");
|
||||
claims.put("role", "OWNER");
|
||||
claims.put("enterpriseId", enterpriseId);
|
||||
claims.put("sector", sector);
|
||||
return build(claims, enterpriseId);
|
||||
}
|
||||
|
||||
private String build(Map<String, Object> claims, String subject) {
|
||||
long now = System.currentTimeMillis();
|
||||
return Jwts.builder()
|
||||
.setClaims(claims)
|
||||
.setSubject(subject)
|
||||
.setIssuedAt(new Date(now))
|
||||
.setExpiration(new Date(now + expirationMs))
|
||||
.signWith(signingKey(), SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
return Jwts.parserBuilder().setSigningKey(signingKey()).build().parseClaimsJws(token).getBody();
|
||||
}
|
||||
|
||||
public String subject(String token) {
|
||||
return parse(token).getSubject();
|
||||
}
|
||||
|
||||
public String scope(String token) {
|
||||
return String.valueOf(parse(token).get("scope"));
|
||||
}
|
||||
|
||||
public String role(String token) {
|
||||
return String.valueOf(parse(token).get("role"));
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
try {
|
||||
return parse(token).getExpiration().after(new Date());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
spring.application.name=udyamlinc-backend
|
||||
|
||||
server.port=8082
|
||||
server.servlet.context-path=/api/v1/udyam
|
||||
|
||||
# JPA & Hibernate
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.properties.hibernate.default_schema=public
|
||||
spring.jpa.open-in-view=false
|
||||
|
||||
# DataSource — UdyamLinc_DEMO
|
||||
spring.datasource.url=${UDYAM_DB_URL:jdbc:postgresql://localhost:5432/UdyamLinc_DEMO}
|
||||
spring.datasource.username=${UDYAM_DB_USERNAME:postgres}
|
||||
spring.datasource.password=${UDYAM_DB_PASSWORD:root}
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
|
||||
|
||||
# HikariCP
|
||||
spring.datasource.hikari.connection-timeout=10000
|
||||
spring.datasource.hikari.minimum-idle=5
|
||||
spring.datasource.hikari.maximum-pool-size=20
|
||||
spring.datasource.hikari.idle-timeout=600000
|
||||
spring.datasource.hikari.max-lifetime=1800000
|
||||
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
|
||||
# 24 hours
|
||||
jwt.expiration=86400000
|
||||
|
||||
# The Vite dev server for UdyamLinc-UI (5180 is BharatLinc)
|
||||
udyam.cors.allowed-origins=http://localhost:5182,http://127.0.0.1:5182,http://localhost:4173
|
||||
|
||||
# Forecast horizon in months — the deck's 3–6 month early-warning window
|
||||
udyam.forecast.horizon-months=6
|
||||
|
||||
# Composite Stress Score band thresholds; map these to the institution's watchlist
|
||||
udyam.risk.red-threshold=65
|
||||
udyam.risk.watch-threshold=35
|
||||
|
||||
# ── 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.
|
||||
udyam.risk.engine=RULE
|
||||
udyam.risk.model-version=rule-v1.0.0
|
||||
# Where a family lands with no usable feature. Neutral, not innocent: scoring 0
|
||||
# would read as "safe on this dimension" when the truth is that we do not know.
|
||||
udyam.risk.family-prior=40
|
||||
# A rule engine reports low confidence by design.
|
||||
udyam.risk.rule-confidence-ceiling=70
|
||||
# Nightly scoring, an hour behind ingestion so it scores what that job wrote.
|
||||
udyam.risk.scheduled=${UDYAM_SCORING_ENABLED:false}
|
||||
udyam.risk.cron=0 0 3 * * *
|
||||
|
||||
# Seed the demo portfolio (142 enterprises) on first boot
|
||||
udyam.seed.enabled=true
|
||||
|
||||
# ── 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.
|
||||
udyam.ingestion.enabled=${UDYAM_INGESTION_ENABLED:false}
|
||||
udyam.ingestion.cron=0 0 2 * * *
|
||||
udyam.ingestion.zone=Asia/Kolkata
|
||||
udyam.ingestion.http.connect-timeout-ms=10000
|
||||
udyam.ingestion.http.read-timeout-ms=30000
|
||||
|
||||
# Agmarknet mandi prices — data.gov.in Open Government Data API.
|
||||
# Register at https://data.gov.in for a key; without one the run records SKIPPED.
|
||||
udyam.ingestion.datagov.api-key=${DATAGOV_API_KEY:}
|
||||
udyam.ingestion.datagov.base-url=https://api.data.gov.in/resource
|
||||
# "Variety-wise Daily Market Prices Data of Commodity" (Agmarknet)
|
||||
udyam.ingestion.datagov.resource-id=${DATAGOV_RESOURCE_ID:9ef84268-d588-465a-a308-a864a43d0070}
|
||||
udyam.ingestion.datagov.page-limit=1000
|
||||
# Filter names must match the resource's own field names; they differ by resource version.
|
||||
udyam.ingestion.datagov.field.state=state
|
||||
udyam.ingestion.datagov.field.district=district
|
||||
udyam.ingestion.datagov.field.commodity=commodity
|
||||
udyam.ingestion.mandi.lookback-weeks=12
|
||||
|
||||
# Climate — Open-Meteo reanalysis archive. No key. The archive trails real time,
|
||||
# hence the lag; the baseline is fetched once per district and cached as normals.
|
||||
udyam.ingestion.climate.base-url=https://archive-api.open-meteo.com/v1/archive
|
||||
udyam.ingestion.climate.timezone=Asia/Kolkata
|
||||
udyam.ingestion.climate.lookback-weeks=12
|
||||
udyam.ingestion.climate.lag-days=5
|
||||
udyam.ingestion.climate.heat-threshold-c=40
|
||||
udyam.ingestion.climate.baseline-from=1995-01-01
|
||||
udyam.ingestion.climate.baseline-to=2024-12-31
|
||||
|
||||
# ── Feature store (ARCHITECTURE Phase C) ────────────────────
|
||||
# Window sizes for the derivations. A level is not a feature; every one of these
|
||||
# turns a level into a change, a ratio or an anomaly against one of these windows.
|
||||
udyam.features.price-window-weeks=8
|
||||
udyam.features.recent-window-weeks=4
|
||||
udyam.features.climate-window-weeks=8
|
||||
# Below this many same-season observations a z-score is noise, so it stays null.
|
||||
udyam.features.min-zscore-samples=6
|
||||
|
||||
logging.level.com.lincfusion.udyamlinc=INFO
|
||||
Reference in New Issue
Block a user