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