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>
410 lines
22 KiB
TypeScript
410 lines
22 KiB
TypeScript
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
|
||
}
|