Files
udyamlinc/src/lib/demo/enterprises.ts
T
LincPayandClaude Opus 5 f430f85202 chore: initial commit — UdyamLinc UI, phases A-E
Vite/React 19 console and enterprise PWA. Officer console screens, offline
ledger and sync, data feeds and trip-wire rules screens with freshness chip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:24 +05:30

410 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
Band,
Enterprise,
ForecastPoint,
ReasonCode,
Sector,
StressScore,
SubScores,
TripWire,
} from '../api'
import { monthWindow, pickInt, pickOne, rng, seedOf, shuffled } from './seed'
/* ------------------------------------------------------------------ */
/* The demo portfolio: 142 enterprises across 5 sector packs and 4 */
/* blocks. The six named on the deck's wireframe slide are pinned to */
/* their exact submitted scores; the rest are generated deterministically*/
/* so band counts always total 9 / 31 / 102. */
/* ------------------------------------------------------------------ */
export const BANDS: Record<Band, { label: string; labelHi: string; cls: string; dot: string }> = {
RED: { label: 'High risk', labelHi: 'ज़्यादा जोखिम', cls: 'band-red', dot: 'bg-risk-red' },
WATCH: { label: 'Watch', labelHi: 'निगरानी', cls: 'band-watch', dot: 'bg-amber' },
STANDARD: { label: 'Standard', labelHi: 'ठीक', cls: 'band-standard', dot: 'bg-green' },
}
/** Band thresholds map to the institution's own watchlist categories (deck slide 3). */
export const BAND_THRESHOLD = { red: 65, watch: 35 }
export function bandOf(score: number): Band {
if (score >= BAND_THRESHOLD.red) return 'RED'
if (score >= BAND_THRESHOLD.watch) return 'WATCH'
return 'STANDARD'
}
export const SECTORS: Record<Sector, { label: string; labelHi: string; short: string }> = {
DAIRY: { label: 'Dairy', labelHi: 'डेयरी', short: 'Dairy' },
POULTRY: { label: 'Poultry', labelHi: 'मुर्गी पालन', short: 'Poultry' },
FOOD_PROC: { label: 'Food processing', labelHi: 'खाद्य प्रसंस्करण', short: 'Food proc.' },
HANDICRAFTS: { label: 'Handicrafts', labelHi: 'हस्तशिल्प', short: 'Handicrafts' },
RURAL_RETAIL: { label: 'Rural retail', labelHi: 'ग्रामीण दुकान', short: 'Rural retail' },
}
export const SECTOR_LIST = Object.keys(SECTORS) as Sector[]
export const BLOCKS = ['Sehore', 'Vidisha', 'Raisen', 'Bhopal'] as const
export const BLOCK_DISTRICT: Record<string, string> = {
Sehore: 'Sehore',
Vidisha: 'Vidisha',
Raisen: 'Raisen',
Bhopal: 'Bhopal',
}
/** The six enterprises printed on the wireframe slide — reproduced exactly. */
const PINNED: { enterpriseId: string; name: string; nameHi: string; sector: Sector; block: string; score: number }[] = [
{ enterpriseId: 'MSME-4417', name: 'Sehore Poultry Farm', nameHi: 'सीहोर पोल्ट्री फ़ार्म', sector: 'POULTRY', block: 'Sehore', score: 79 },
{ enterpriseId: 'MSME-3902', name: 'Sharda Dairy Collective', nameHi: 'शारदा डेयरी समूह', sector: 'DAIRY', block: 'Vidisha', score: 71 },
{ enterpriseId: 'MSME-5120', name: 'Anand Food Products', nameHi: 'आनंद फ़ूड प्रोडक्ट्स', sector: 'FOOD_PROC', block: 'Raisen', score: 58 },
{ enterpriseId: 'MSME-2861', name: 'Gramin Kirana Store', nameHi: 'ग्रामीण किराना स्टोर', sector: 'RURAL_RETAIL', block: 'Sehore', score: 41 },
{ enterpriseId: 'MSME-6033', name: 'Meera Handicrafts', nameHi: 'मीरा हस्तशिल्प', sector: 'HANDICRAFTS', block: 'Bhopal', score: 22 },
{ enterpriseId: 'MSME-4488', name: 'Krishna Dairy Unit', nameHi: 'कृष्णा डेयरी यूनिट', sector: 'DAIRY', block: 'Vidisha', score: 18 },
]
const NAME_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',
]
const NAME_PREFIX_HI = [
'श्री', 'माँ नर्मदा', 'जय किसान', 'सर्वोदय', 'अन्नपूर्णा', 'विंध्य', 'बेतवा', 'नर्मदा',
'आदर्श', 'नव जीवन', 'ग्राम शक्ति', 'किसान मित्र', 'संस्कार', 'प्रेरणा', 'सनराइज़',
'भगीरथ', 'मालवा', 'सहयोग', 'एकता', 'प्रगति', 'सुविधा', 'अनमोल', 'निर्मल', 'वैभव',
]
const SECTOR_SUFFIX: Record<Sector, { en: string[]; hi: string[] }> = {
DAIRY: { en: ['Dairy', 'Milk Centre', 'Dairy Unit', 'Doodh Sangh'], hi: ['डेयरी', 'दूध केंद्र', 'डेयरी यूनिट', 'दूध संघ'] },
POULTRY: { en: ['Poultry Farm', 'Layer Farm', 'Poultry Unit'], hi: ['पोल्ट्री फ़ार्म', 'लेयर फ़ार्म', 'पोल्ट्री यूनिट'] },
FOOD_PROC: { en: ['Food Products', 'Flour Mill', 'Masala Udyog', 'Agro Foods'], hi: ['फ़ूड प्रोडक्ट्स', 'आटा चक्की', 'मसाला उद्योग', 'एग्रो फ़ूड्स'] },
HANDICRAFTS: { en: ['Handicrafts', 'Handloom', 'Craft Works'], hi: ['हस्तशिल्प', 'हथकरघा', 'क्राफ़्ट वर्क्स'] },
RURAL_RETAIL: { en: ['Kirana Store', 'General Store', 'Provision Store'], hi: ['किराना स्टोर', 'जनरल स्टोर', 'प्रोविज़न स्टोर'] },
}
/** Sector weights applied to the five sub-scores (deck feature 2). */
export const SECTOR_WEIGHTS: Record<Sector, SubScores> = {
DAIRY: { liquidity: 0.28, behavioural: 0.2, trendVolatility: 0.18, obligationLoad: 0.19, externalShock: 0.15 },
POULTRY: { liquidity: 0.3, behavioural: 0.22, trendVolatility: 0.17, obligationLoad: 0.14, externalShock: 0.17 },
FOOD_PROC: { liquidity: 0.26, behavioural: 0.22, trendVolatility: 0.2, obligationLoad: 0.18, externalShock: 0.14 },
HANDICRAFTS: { liquidity: 0.24, behavioural: 0.18, trendVolatility: 0.2, obligationLoad: 0.16, externalShock: 0.22 },
RURAL_RETAIL: { liquidity: 0.3, behavioural: 0.24, trendVolatility: 0.18, obligationLoad: 0.18, externalShock: 0.1 },
}
/** Band plan so the totals always match the deck: 9 red, 31 watch, 102 standard. */
const TOTAL = 142
const TARGET = { RED: 9, WATCH: 31, STANDARD: 102 }
function buildBandPlan(): Band[] {
const remaining: Band[] = []
const pinnedCounts = PINNED.reduce<Record<Band, number>>(
(acc, p) => {
acc[bandOf(p.score)]++
return acc
},
{ RED: 0, WATCH: 0, STANDARD: 0 },
)
;(Object.keys(TARGET) as Band[]).forEach((b) => {
for (let i = 0; i < TARGET[b] - pinnedCounts[b]; i++) remaining.push(b)
})
return shuffled(rng(seedOf('udyamlinc-band-plan')), remaining)
}
/**
* 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.
*/
function scoreForBand(r: () => number, band: Band): number {
if (band === 'RED') return pickInt(r, 66, 78)
if (band === 'WATCH') return pickInt(r, 36, 64)
return pickInt(r, 8, 34)
}
/** A 6-month score history that lands on the current score, drifting the way the band implies. */
function trendFor(r: () => number, score: number, band: Band): number[] {
const drift = band === 'RED' ? 1 : band === 'WATCH' ? 0.4 : -0.6
const out: number[] = []
let v = score - drift * 9
for (let i = 0; i < 6; i++) {
out.push(Math.max(4, Math.min(97, Math.round(v))))
v += drift * 3 + (r() - 0.5) * 7
}
out[5] = score
return out
}
function build(): Enterprise[] {
const plan = buildBandPlan()
const list: Enterprise[] = []
PINNED.forEach((p, i) => {
const r = rng(seedOf(p.enterpriseId))
const band = bandOf(p.score)
list.push({
id: i + 1,
enterpriseId: p.enterpriseId,
name: p.name,
nameHi: p.nameHi,
sector: p.sector,
block: p.block,
district: BLOCK_DISTRICT[p.block],
ownerCount: pickInt(r, 1, 4),
vintageMonths: pickInt(r, 14, 96),
loanOutstanding: pickInt(r, 6, 34) * 10_000,
monthlyInstalment: pickInt(r, 24, 96) * 250,
score: p.score,
band,
trend: trendFor(r, p.score, band),
lastUpdated: new Date().toISOString(),
})
})
for (let i = 0; i < TOTAL - PINNED.length; i++) {
const enterpriseId = `MSME-${1000 + ((i * 37 + 113) % 8000)}`
const r = rng(seedOf(enterpriseId))
const sector = pickOne(r, SECTOR_LIST)
const block = pickOne(r, BLOCKS)
const band = plan[i]
const score = scoreForBand(r, band)
const nameIdx = pickInt(r, 0, NAME_PREFIX.length - 1)
const sufIdx = pickInt(r, 0, SECTOR_SUFFIX[sector].en.length - 1)
list.push({
id: PINNED.length + i + 1,
enterpriseId,
name: `${NAME_PREFIX[nameIdx]} ${SECTOR_SUFFIX[sector].en[sufIdx]}`,
nameHi: `${NAME_PREFIX_HI[nameIdx]} ${SECTOR_SUFFIX[sector].hi[sufIdx]}`,
sector,
block,
district: BLOCK_DISTRICT[block],
ownerCount: pickInt(r, 1, 4),
vintageMonths: pickInt(r, 8, 120),
loanOutstanding: pickInt(r, 4, 40) * 10_000,
monthlyInstalment: pickInt(r, 16, 110) * 250,
score,
band,
trend: trendFor(r, score, band),
lastUpdated: new Date().toISOString(),
})
}
// Highest risk first — the order the console works the book in.
return list.sort((a, b) => b.score - a.score)
}
/** The whole demo portfolio, built once per page load. */
export const ENTERPRISES: Enterprise[] = build()
export function findEnterprise(enterpriseId: string): Enterprise | undefined {
return ENTERPRISES.find((e) => e.enterpriseId === enterpriseId)
}
// ── Sub-scores, drivers and trip-wires ──────────────────────────
/** Slide-6 driver weights for the pinned enterprise, so the demo matches the submission. */
const PINNED_SUBSCORES: Record<string, SubScores> = {
'MSME-4417': { liquidity: 26, behavioural: 19, trendVolatility: 14, obligationLoad: 11, externalShock: 9 },
}
export function subScoresFor(e: Enterprise): SubScores {
const pinned = PINNED_SUBSCORES[e.enterpriseId]
if (pinned) return pinned
const r = rng(seedOf(`${e.enterpriseId}:sub`))
const w = SECTOR_WEIGHTS[e.sector]
// Distribute the composite across the five families, weighted by sector, with jitter.
const raw = {
liquidity: e.score * w.liquidity * (0.8 + r() * 0.5),
behavioural: e.score * w.behavioural * (0.8 + r() * 0.5),
trendVolatility: e.score * w.trendVolatility * (0.8 + r() * 0.5),
obligationLoad: e.score * w.obligationLoad * (0.8 + r() * 0.5),
externalShock: e.score * w.externalShock * (0.8 + r() * 0.5),
}
return {
liquidity: Math.round(raw.liquidity),
behavioural: Math.round(raw.behavioural),
trendVolatility: Math.round(raw.trendVolatility),
obligationLoad: Math.round(raw.obligationLoad),
externalShock: Math.round(raw.externalShock),
}
}
export function stressScoreFor(e: Enterprise): StressScore {
return {
enterpriseId: e.enterpriseId,
score: e.score,
band: e.band,
subScores: subScoresFor(e),
computedAt: new Date().toISOString(),
modelVersion: 'lgbm-q-v0.9.3',
}
}
export const DRIVER_META: { key: keyof SubScores; label: string; labelHi: string }[] = [
{ key: 'liquidity', label: 'Liquidity', labelHi: 'नकदी' },
{ key: 'behavioural', label: 'Behavioural (UPI)', labelHi: 'लेन-देन व्यवहार' },
{ key: 'trendVolatility', label: 'Trend & volatility', labelHi: 'उतार-चढ़ाव' },
{ key: 'obligationLoad', label: 'Obligation load', labelHi: 'किस्त का बोझ' },
{ key: 'externalShock', label: 'External shock', labelHi: 'बाहरी झटका' },
]
/** Sector-specific sentence templates — the deck's "models the trade, not one generic model". */
const REASON_TEXT: Record<Sector, Record<keyof SubScores, { en: string; hi: string }>> = {
POULTRY: {
liquidity: { en: 'Feed costs up 18% against a 9% fall in egg realisation.', hi: 'दाना 18% महँगा और अंडे का भाव 9% गिरा है।' },
behavioural: { en: 'UPI collections down 23% over eight weeks.', hi: 'आठ हफ़्तों में UPI से आया पैसा 23% घटा है।' },
trendVolatility: { en: 'Weekly sales swinging twice as wide as last season.', hi: 'हफ़्तेवार बिक्री पिछले सीज़न से दोगुनी ऊपर-नीचे हो रही है।' },
obligationLoad: { en: 'Instalment now takes 41% of net monthly cash.', hi: 'किस्त अब महीने की कमाई का 41% ले रही है।' },
externalShock: { en: 'Heat anomaly flagged in the block for the next fortnight.', hi: 'अगले पंद्रह दिन ब्लॉक में गर्मी की चेतावनी है।' },
},
DAIRY: {
liquidity: { en: 'Fodder cost up 14% while procurement rate stayed flat.', hi: 'चारा 14% महँगा हुआ, दूध का रेट वही है।' },
behavioural: { en: 'Society payment cycle stretched from 10 to 18 days.', hi: 'समिति से भुगतान 10 दिन की जगह 18 दिन में आ रहा है।' },
trendVolatility: { en: 'Daily yield varying sharply across the last six weeks.', hi: 'छह हफ़्तों में रोज़ का दूध बहुत घट-बढ़ रहा है।' },
obligationLoad: { en: 'Two instalments now fall in the same fortnight.', hi: 'दो किस्तें एक ही पखवाड़े में आ रही हैं।' },
externalShock: { en: 'Rainfall deficit in the block is raising fodder prices.', hi: 'ब्लॉक में कम बारिश से चारा महँगा हो रहा है।' },
},
FOOD_PROC: {
liquidity: { en: 'Raw material prepayment is locking up working capital.', hi: 'कच्चे माल का अग्रिम भुगतान पूँजी रोक रहा है।' },
behavioural: { en: 'Buyer settlements slipping past the agreed 15 days.', hi: 'खरीदार 15 दिन की जगह देर से पैसा दे रहे हैं।' },
trendVolatility: { en: 'Order volume swinging with festival demand.', hi: 'त्योहारों के साथ ऑर्डर बहुत घट-बढ़ रहे हैं।' },
obligationLoad: { en: 'Instalment plus rent exceeds 50% of monthly inflow.', hi: 'किस्त और किराया मिलकर आधी से ज़्यादा कमाई ले रहे हैं।' },
externalShock: { en: 'Mandi price shock in the primary input crop.', hi: 'मुख्य फ़सल के मंडी भाव में तेज़ बदलाव है।' },
},
HANDICRAFTS: {
liquidity: { en: 'Finished stock is unsold going into the lean quarter.', hi: 'तैयार माल बिका नहीं और सुस्त तिमाही आ रही है।' },
behavioural: { en: 'Top buyer accounts for 68% of all receipts.', hi: 'कुल आमदनी का 68% सिर्फ़ एक खरीदार से आता है।' },
trendVolatility: { en: 'Receipts concentrated in two months of the year.', hi: 'साल की कमाई सिर्फ़ दो महीनों में सिमटी है।' },
obligationLoad: { en: 'Repayment schedule ignores the seasonal lull.', hi: 'किस्त का समय सुस्त सीज़न का ध्यान नहीं रखता।' },
externalShock: { en: 'Exhibition calendar thinner than last year.', hi: 'पिछले साल से कम प्रदर्शनियाँ लगी हैं।' },
},
RURAL_RETAIL: {
liquidity: { en: 'Credit sales to customers up 31% this quarter.', hi: 'इस तिमाही उधार बिक्री 31% बढ़ी है।' },
behavioural: { en: 'Daily UPI receipt count down for six straight weeks.', hi: 'छह हफ़्तों से रोज़ के UPI लेन-देन घट रहे हैं।' },
trendVolatility: { en: 'Footfall dropping since the new road closure.', hi: 'रास्ता बंद होने से ग्राहक कम आ रहे हैं।' },
obligationLoad: { en: 'Stock restocking clashes with the instalment date.', hi: 'माल भरने और किस्त की तारीख़ एक साथ पड़ रही है।' },
externalShock: { en: 'Local market disruption flagged in the block.', hi: 'ब्लॉक में स्थानीय बाज़ार में गड़बड़ी दर्ज है।' },
},
}
/** Top-3 SHAP drivers as plain-language sentences (deck feature 4). */
export function reasonCodesFor(e: Enterprise): ReasonCode[] {
const sub = subScoresFor(e)
return DRIVER_META.map((d) => ({
driver: d.label,
driverHi: d.labelHi,
weight: sub[d.key],
sentenceEn: REASON_TEXT[e.sector][d.key].en,
sentenceHi: REASON_TEXT[e.sector][d.key].hi,
}))
.sort((a, b) => b.weight - a.weight)
.slice(0, 3)
}
/** Rule-based alerts that fire independently of the model, for auditability (feature 3). */
export function tripWiresFor(e: Enterprise): TripWire[] {
const r = rng(seedOf(`${e.enterpriseId}:tw`))
const sub = subScoresFor(e)
const dscr = (1.9 - e.score / 100).toFixed(2)
return [
{
code: 'TW-01',
label: 'Projected DSCR below 1.0',
labelHi: 'किस्त चुकाने की क्षमता 1.0 से नीचे',
fired: e.band === 'RED',
detail: `P10 debt-service coverage projected at ${dscr}`,
threshold: 'DSCR(P10) < 1.00',
},
{
code: 'TW-02',
label: 'UPI inflow drop over 8 weeks',
labelHi: 'आठ हफ़्तों में UPI आमदनी में गिरावट',
fired: sub.behavioural >= 15,
detail: `Rolling 8-week inflow down ${pickInt(r, 12, 34)}%`,
threshold: 'Δ inflow < −20%',
},
{
code: 'TW-03',
label: 'Ledger silent for 14 days',
labelHi: '14 दिन से हिसाब दर्ज नहीं',
fired: e.band !== 'STANDARD' && r() > 0.55,
detail: `Last entry ${pickInt(r, 2, 26)} days ago`,
threshold: 'no entry ≥ 14 days',
},
{
code: 'TW-04',
label: 'Instalment share of inflow above 40%',
labelHi: 'किस्त आमदनी का 40% से ज़्यादा',
fired: sub.obligationLoad >= 11,
detail: `Instalment is ${pickInt(r, 22, 58)}% of median monthly inflow`,
threshold: 'EMI / inflow > 40%',
},
{
code: 'TW-05',
label: 'Climate or mandi shock in block',
labelHi: 'ब्लॉक में मौसम या मंडी का झटका',
fired: sub.externalShock >= 9,
detail: 'IMD heat anomaly + Agmarknet input price spike',
threshold: 'anomaly z-score > 1.5',
},
]
}
// ── Forecast ────────────────────────────────────────────────────
/**
* Six-month quantile path. P50 drifts down for stressed enterprises; P10/P90
* fan out with horizon, because uncertainty compounds. The obligation line is
* the instalment plus fixed costs due that month — where P10 dips under it,
* the enterprise escalates.
*/
export function forecastFor(e: Enterprise, horizon = 6): ForecastPoint[] {
const r = rng(seedOf(`${e.enterpriseId}:fc`))
const months = monthWindow(horizon)
const base = 18_000 + (100 - e.score) * 420 + pickInt(r, -2000, 2500)
const drift = e.band === 'RED' ? -0.14 : e.band === 'WATCH' ? -0.05 : 0.035
const fixed = e.monthlyInstalment + pickInt(r, 8, 22) * 400
const points = months.map((m, h) => {
const centre = base * (1 + drift * (h + 1)) + (r() - 0.5) * 1800
const spread = centre * (0.16 + h * 0.045) + 900
return {
month: m.key,
label: m.label,
labelHi: m.labelHi,
p10: Math.round(centre - spread),
p50: Math.round(centre),
p90: Math.round(centre + spread * 0.85),
obligation: Math.round(fixed + h * 120),
}
})
// The wireframe slide quotes a ₹12,400 shortfall three months out for the
// headline enterprise — pin it so the prototype matches the submitted deck.
const pin = PINNED_SHORTFALL[e.enterpriseId]
if (pin && points.length > pin.horizon) {
const p = points[pin.horizon]
p.p10 = p.obligation - pin.amount
p.p50 = Math.max(p.p50, p.p10 + 4_000)
p.p90 = Math.max(p.p90, p.p50 + 5_000)
// Everything before the pinned month must still clear its obligation,
// or the "first breach" would land earlier than the deck says.
for (let i = 0; i < pin.horizon; i++) {
points[i].p10 = Math.max(points[i].p10, points[i].obligation + 1_500)
points[i].p50 = Math.max(points[i].p50, points[i].p10 + 3_000)
points[i].p90 = Math.max(points[i].p90, points[i].p50 + 4_000)
}
}
return points
}
/** Enterprise → { months ahead of the first breach, ₹ gap }, as printed on the deck. */
const PINNED_SHORTFALL: Record<string, { horizon: number; amount: number }> = {
'MSME-4417': { horizon: 2, amount: 12_400 },
}
/** First month where the downside case fails to cover obligations, or null. */
export function breachPoint(points: ForecastPoint[]): ForecastPoint | null {
return points.find((p) => p.p10 < p.obligation) ?? null
}
/** Size of the projected gap at the breach month. */
export function shortfallOf(points: ForecastPoint[]): number {
const b = breachPoint(points)
return b ? Math.max(0, b.obligation - b.p10) : 0
}