File size: 15,571 Bytes
5c35138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
"""Exogenous-actor simulators for SimMart.

Each simulator is a pure function that consumes the current ledger + active
crises + RNG and returns demand/event signals for the ledger's daily tick.

Anchored in RETAIL_GROUND_TRUTH.md Β§3 (external actor behaviours) and Β§6
(KPI realism).  Randomness is injected; no RNGs created inside.

Public API:
    customer_daily_demand(...)           β†’ {category: units_today}
    competitor_weekly_events(...)        β†’ List[CompetitorEvent]
    active_share_drain_pct(...)          β†’ decayed aggregate share drain %
    supplier_daily_reliability(...)      β†’ {sku_id: delivery_mult in [0,1]}
    franchisee_weekly_complaints(...)    β†’ List[Complaint]
    regulator_weekly_events(...)         β†’ List[CrisisEvent]  (usu. empty)
    rider_daily_sla_hit_rate(...)        β†’ float in [0, 100]
    update_weekly_nps(prev, signals)     β†’ float
    update_weekly_basket_size(prev, ...)  β†’ float
    update_weekly_footfall(prev, ...)    β†’ float
    update_weekly_repeat_purchase(prev, ...) β†’ float
"""

from __future__ import annotations

import random
from typing import Any, Dict, List, Optional

try:
    from ..models import (
        CompanyLedger,
        CompetitorEvent,
        Complaint,
        CrisisEvent,
    )
    from . import economics as E
except (ImportError, ModuleNotFoundError):
    from models import (
        CompanyLedger,
        CompetitorEvent,
        Complaint,
        CrisisEvent,
    )
    from server import economics as E


# ---------------------------------------------------------------------------
# Customer
# ---------------------------------------------------------------------------

def customer_daily_demand(
    ledger: CompanyLedger,
    day_of_quarter: int,
    nps: float,
    share_drain_pct: float,
    active_crises: List[CrisisEvent],
    rng: random.Random,
    pending_revenue_mult: float = 1.0,
) -> Dict[str, float]:
    """Compute today's demand in units per category across the network.

    Inputs combine:
        β€’ Salary-cycle multiplier (day-of-month)
        β€’ Day-of-week boost (Sat/Sun)
        β€’ NPS effect (every 10 NPS points ~= 3% demand)
        β€’ Competitor share drain (decayed aggregate)
        β€’ Festival multiplier (category- and region-specific, here applied network-wide
          with a small discount when regions != 'ALL')
        β€’ Active crisis demand effects (crisis.affected.demand_mult)
        β€’ Small noise jitter
        β€’ Pending weekly multiplier from approved campaigns/discounts
    """
    baseline_daily = E.BASELINE_WEEKLY_REVENUE_INR / 7.0

    salary_mult = E.salary_cycle_multiplier(day_of_quarter)
    dow = ((day_of_quarter - 1) % 7) + 1
    dow_mult = 1.15 if dow in (6, 7) else 1.00

    nps_mult = 1.0 + (nps - E.STARTING_NPS) * 0.003
    competitor_mult = max(0.70, 1.0 - share_drain_pct / 100.0)
    noise = rng.uniform(0.92, 1.08)

    global_mult = salary_mult * dow_mult * nps_mult * competitor_mult * noise * pending_revenue_mult

    # Festival effects
    fest = E.festival_for_day(day_of_quarter)

    demand: Dict[str, float] = {}
    for category, share in E.CATEGORY_REVENUE_SHARE.items():
        category_revenue = baseline_daily * share * global_mult

        if fest:
            applies = (
                "ALL" in fest.get("regions", [])
                or (fest.get("regions") and any(r in ledger.cities for r in fest["regions"]))
            )
            if applies and (fest["categories"] == ["ALL"] or category in fest["categories"]):
                region_scale = 1.0 if "ALL" in fest.get("regions", []) else 0.55
                category_revenue *= 1.0 + (fest["demand_mult"] - 1.0) * region_scale

        for crisis in active_crises:
            if not crisis.active:
                continue
            affected = crisis.affected or {}
            cat_match = affected.get("category") in (category, "ALL", None)
            if cat_match and affected.get("demand_mult") is not None:
                category_revenue *= float(affected["demand_mult"])

        avg_price = _avg_price_per_category(category, ledger)
        demand[category] = max(0.0, category_revenue / max(1.0, avg_price))

    return demand


def _avg_price_per_category(category: str, ledger: CompanyLedger) -> float:
    prices = [sku["price_inr"] for sku in ledger.sku_catalogue.values() if sku["category"] == category]
    return sum(prices) / len(prices) if prices else 100.0


# ---------------------------------------------------------------------------
# Competitor
# ---------------------------------------------------------------------------

def competitor_weekly_events(
    ledger: CompanyLedger,
    week_of_quarter: int,
    rng: random.Random,
) -> List[CompetitorEvent]:
    """Generate 0–3 competitor events this week.

    Quick-commerce companies (JioMart / Blinkit / Zepto) act more aggressively
    as the festive quarter progresses.  Impact ranges from 0.5–4.0 percentage
    points of share (per RETAIL_GROUND_TRUTH Β§3 bullet `Quick-commerce share
    drain 5–15% monthly`).
    """
    events: List[CompetitorEvent] = []
    p_event = 0.35 + 0.025 * week_of_quarter
    p_event = min(0.85, p_event)

    while rng.random() < p_event and len(events) < 3:
        event_type = rng.choices(
            ["price_cut", "dark_store_open", "city_entry", "loyalty_push", "bulk_ad"],
            weights=[0.32, 0.22, 0.14, 0.22, 0.10],
        )[0]
        competitor = rng.choices(
            ["JioMart", "Blinkit", "Zepto", "DMart", "Reliance Fresh"],
            weights=[0.28, 0.22, 0.18, 0.18, 0.14],
        )[0]
        region_pool = ledger.cities + ["NCR", "Kolkata", "Bhubaneswar"]
        region = rng.choice(region_pool)
        impact_pct = round(rng.uniform(0.5, 4.0), 2)
        events.append(CompetitorEvent(
            competitor=competitor,
            event_type=event_type,
            region=region,
            impact_pct=impact_pct,
            week=week_of_quarter,
            description=(
                f"{competitor} {event_type.replace('_', ' ')} in {region} "
                f"(~{impact_pct:.1f}pt share impact)"
            ),
        ))
        p_event *= 0.45

    return events


def active_share_drain_pct(
    recent_events: List[CompetitorEvent],
    current_week: int,
    decay_per_week: float = 0.6,
) -> float:
    """Decayed-aggregate share drain from competitor events within the last ~3 weeks.

    Capped at 15 pts so the customer demand multiplier never drops below 0.85.
    """
    total = 0.0
    for ev in recent_events:
        age_weeks = max(0, current_week - ev.week)
        total += ev.impact_pct * (decay_per_week ** age_weeks)
    return min(15.0, total)


# ---------------------------------------------------------------------------
# Supplier
# ---------------------------------------------------------------------------

def supplier_daily_reliability(
    ledger: CompanyLedger,
    day_of_quarter: int,
    active_crises: List[CrisisEvent],
    rng: random.Random,
) -> Dict[str, float]:
    """Per-SKU PO-delivery multiplier today.

    Mostly 0.92–0.98; dips under monsoon (perishables) or active supply-chain
    crises.  Matters only for SKUs where a PO lands today (environment wires
    this up when processing approved po.place proposals).
    """
    monsoon_hit = rng.random() < 0.04
    mult: Dict[str, float] = {}

    for sku_id, sku in ledger.sku_catalogue.items():
        reliability = 0.92 + rng.uniform(-0.05, 0.06)
        if monsoon_hit and E.CATEGORY_PERISHABLE[sku["category"]]:
            reliability *= E.MONSOON_SUPPLY_DAMPENER

        for crisis in active_crises:
            if not crisis.active:
                continue
            affected = crisis.affected or {}
            if affected.get("supply_mult") is not None:
                if affected.get("category") in (sku["category"], "ALL", None):
                    reliability *= float(affected["supply_mult"])

        mult[sku_id] = max(0.20, min(1.00, reliability))

    return mult


# ---------------------------------------------------------------------------
# Franchisee
# ---------------------------------------------------------------------------

COMPLAINT_ISSUE_POOL: List[str] = [
    "atta delivery delayed 3 days",
    "POS app crashed during salary-day rush",
    "discount code settlement stuck",
    "expired stock sent in last delivery",
    "price changed without 24h notice",
    "staff training session never happened",
    "delivery SLA missed 2 days running",
    "HQ promo squeezed franchise margin",
    "cold-chain reefer missed Sunday visit",
    "dry run of new planogram confused customers",
    "shrinkage audit team never showed",
    "receivables settlement 10 days late",
]


def franchisee_weekly_complaints(
    ledger: CompanyLedger,
    week_of_quarter: int,
    stockout_rate_by_category: Dict[str, float],
    sla_hit_rate_pct: float,
    rng: random.Random,
) -> List[Complaint]:
    """Franchise complaints driven by health score + systemic pain (stockouts, SLA)."""
    complaints: List[Complaint] = []

    avg_stockout = (
        sum(stockout_rate_by_category.values()) / max(1, len(stockout_rate_by_category))
        if stockout_rate_by_category
        else 0.0
    )
    high_pain = avg_stockout > 10.0 or sla_hit_rate_pct < 85.0

    for fr in ledger.franchisees:
        p_complaint = (1.0 - fr["health_score"]) * 0.25
        if high_pain:
            p_complaint += 0.18

        if rng.random() < p_complaint:
            severity_roll = rng.random()
            if severity_roll < 0.15:
                severity = "high"
            elif severity_roll < 0.55:
                severity = "med"
            else:
                severity = "low"

            complaints.append(Complaint(
                franchise_id=fr["franchise_id"],
                city=fr["city"],
                issue=rng.choice(COMPLAINT_ISSUE_POOL),
                severity=severity,
                week_filed=week_of_quarter,
            ))

            # Health score drifts down a notch when a complaint is filed
            fr["health_score"] = max(0.0, fr["health_score"] - 0.02)
            fr["complaints_open"] = int(fr.get("complaints_open", 0)) + 1

    return complaints


# ---------------------------------------------------------------------------
# Regulator (placeholder; crises.py drives FSSAI-style events)
# ---------------------------------------------------------------------------

def regulator_weekly_events(
    ledger: CompanyLedger,
    week_of_quarter: int,
    rng: random.Random,
) -> List[CrisisEvent]:
    """Return 0 events in the common case; rare FSSAI/labelling nudges live here.

    Main regulator crises (C6 FSSAI raid, C10 labelling change) are scheduled
    by crises.py at episode-start; this simulator just covers ad-hoc minor
    nudges that don't rise to the level of a named crisis.
    """
    events: List[CrisisEvent] = []
    if rng.random() < 0.05:
        events.append(CrisisEvent(
            crisis_id="REG-MINOR",
            name="Minor regulatory notice",
            started_day=1 + (week_of_quarter - 1) * 7,
            duration_days=3,
            severity="low",
            affected={"category": rng.choice(["fresh", "packaged", "seasonal"])},
            description="Local FSSAI inspector flagged minor labelling compliance issue.",
        ))
    return events


# ---------------------------------------------------------------------------
# Rider (delivery SLA)
# ---------------------------------------------------------------------------

def rider_daily_sla_hit_rate(
    day_of_quarter: int,
    active_crises: List[CrisisEvent],
    rng: random.Random,
) -> float:
    """Today's SLA hit rate in %.  Default β‰ˆ 88–92%; tanks under strikes or monsoon."""
    base = E.STARTING_SLA_HIT_RATE_PCT * rng.uniform(0.96, 1.02)

    for crisis in active_crises:
        if not crisis.active:
            continue
        affected = crisis.affected or {}
        if affected.get("sla_mult") is not None:
            base *= float(affected["sla_mult"])

    return max(45.0, min(99.0, base))


# ---------------------------------------------------------------------------
# Weekly KPI updaters (NPS, basket, footfall, repeat)
# ---------------------------------------------------------------------------

def update_weekly_nps(
    prev_nps: float,
    stockout_rate_pct: float,
    sla_hit_rate_pct: float,
    pending_nps_delta: float,
    high_severity_complaints: int,
    rng: random.Random,
) -> float:
    """Drift NPS week-over-week based on operational + marketing signals.

    NPS is bidirectional: pain pushes it down, but when ops are clean it
    regresses toward the starting baseline so a CEO who fixes a crisis
    actually sees recovery (rather than NPS being a one-way trapdoor).
    """
    new_nps = prev_nps
    new_nps -= 0.5 * max(0.0, stockout_rate_pct - 5.0)
    new_nps -= 0.3 * max(0.0, 90.0 - sla_hit_rate_pct)
    new_nps += pending_nps_delta
    new_nps -= 0.6 * high_severity_complaints

    # Recovery toward baseline when stockouts are under control. SLA can
    # dip during prep weeks even when shelves are full, so the recovery
    # gate is stockout-only β€” otherwise NPS stays pegged at the floor.
    if stockout_rate_pct < 10.0:
        new_nps += E.NPS_RECOVERY_RATE * (E.STARTING_NPS - prev_nps)

    new_nps += rng.uniform(-0.8, 0.8)
    return max(0.0, min(80.0, new_nps))


def update_weekly_basket_size(
    prev_basket_inr: float,
    stockout_rate_pct: float,
    festival_weight: float,
    rng: random.Random,
) -> float:
    """Basket size drifts down under stockouts, up on festival weeks."""
    new_basket = prev_basket_inr
    new_basket *= 1.0 - 0.005 * max(0.0, stockout_rate_pct - 5.0)
    new_basket *= 1.0 + 0.08 * festival_weight
    new_basket *= rng.uniform(0.97, 1.03)
    return max(200.0, min(1200.0, new_basket))


def update_weekly_footfall(
    prev_footfall: float,
    share_drain_pct: float,
    festival_weight: float,
    stockout_rate_pct: float,
    rng: random.Random,
) -> float:
    """Average footfall per store per day (weekly avg)."""
    new_ff = prev_footfall
    new_ff *= 1.0 - 0.015 * share_drain_pct
    new_ff *= 1.0 + 0.22 * festival_weight
    new_ff *= 1.0 - 0.006 * max(0.0, stockout_rate_pct - 5.0)  # word-of-mouth lag
    new_ff *= rng.uniform(0.96, 1.04)
    return max(150.0, min(1500.0, new_ff))


def update_weekly_repeat_purchase(
    prev_repeat_pct: float,
    nps: float,
    pending_loyalty_boost: float,
    rng: random.Random,
) -> float:
    """Repeat-purchase rate tracks NPS with lag plus active loyalty programs."""
    target = 30.0 + 0.35 * nps
    new_repeat = prev_repeat_pct + 0.3 * (target - prev_repeat_pct)
    new_repeat += pending_loyalty_boost
    new_repeat += rng.uniform(-1.0, 1.0)
    return max(15.0, min(70.0, new_repeat))


# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------

def festival_weight_for_week(week_of_quarter: int) -> float:
    """Return a rough in-[0,1] festive weight for the given week.

    Used to feed basket/footfall updaters.  Based on FESTIVAL_CALENDAR density.
    """
    start_day = (week_of_quarter - 1) * 7 + 1
    end_day = week_of_quarter * 7
    total = 0.0
    for d in range(start_day, end_day + 1):
        fest = E.festival_for_day(d)
        if fest:
            total += max(0.0, fest["demand_mult"] - 1.0)
    return min(1.0, total / 3.0)