package controllers import ( "strconv" "time" "doormile/constants" "doormile/db" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" "gorm.io/gorm/clause" ) // Demand forecast: stored by the engine, served with a staffing gap. // // ─── Why a gap and not just a number ────────────────────────────────────── // // docs/prediction-plan.md §2.4 is explicit: "Demand prediction with no consumer // is a dashboard nobody opens." A number like "expect 42 pickups in 641 on // Thursday" is not actionable on its own — nobody knows whether 42 is fine. // // So the read endpoint joins the forecast to the riders actually on duty in // that zone and reports the GAP. That is the thing an operator can act on: // "641 expects 42 and has 6 riders at an average 5 stops each — short by 12". // // It stops short of moving anyone. `rebalance_riders` is still review-only // because no endpoint reassigns riders between zones, and inventing one here // would be a product decision disguised as plumbing. // POST /api/v1/internal/demand-forecast // // Written by AI_engine's forecast job. Upserts on (zone, forday): re-running // the job replaces that day's number rather than accumulating versions, because // the useful question is "what do we currently expect", and the previous // estimate for a day already past is answered by it having been overwritten // before the day arrived. func UpsertDemandForecast(c *fiber.Ctx) error { type item struct { Zone string `json:"zone"` Forday string `json:"forday"` // YYYY-MM-DD Expectedbookings int `json:"expectedbookings"` Model string `json:"model"` Reason string `json:"reason"` Observations int `json:"observations"` Baselinemae *float64 `json:"baselinemae"` Modelmae *float64 `json:"modelmae"` } var body struct { Forecasts []item `json:"forecasts"` } if err := c.BodyParser(&body); err != nil { return utils.BadRequest(c, "invalid request body") } if len(body.Forecasts) == 0 { return utils.BadRequest(c, "forecasts is required") } now := utils.DBNow() rows := make([]models.DemandForecast, 0, len(body.Forecasts)) for _, f := range body.Forecasts { if f.Zone == "" || f.Model == "" { continue } day, err := time.Parse("2006-01-02", f.Forday) if err != nil { continue } if f.Expectedbookings < 0 { // A negative count is a model artefact, not a forecast. The engine // clamps already; this is the second line of defence. f.Expectedbookings = 0 } rows = append(rows, models.DemandForecast{ Zone: f.Zone, Forday: day, Expectedbookings: f.Expectedbookings, Model: f.Model, Reason: f.Reason, Observations: f.Observations, Baselinemae: f.Baselinemae, Modelmae: f.Modelmae, Generatedat: now, }) } if len(rows) == 0 { return utils.BadRequest(c, "no usable forecast rows") } if err := db.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "zone"}, {Name: "forday"}}, DoUpdates: clause.AssignmentColumns([]string{ "expectedbookings", "model", "reason", "observations", "baselinemae", "modelmae", "generatedat", }), }).CreateInBatches(&rows, 200).Error; err != nil { utils.Error("UpsertDemandForecast: write failed", "error", err) return utils.Internal(c, "failed to store the forecast") } return utils.OK(c, fiber.Map{"stored": len(rows)}) } // GET /api/v1/admin/forecast/demand?days=7 // // Tomorrow onward, per zone, with the staffing gap. Doormile staff only, like // the rest of the /admin/ai surface it sits beside. func GetDemandForecast(c *fiber.Ctx) error { days, err := strconv.Atoi(c.Query("days", "7")) if err != nil || days < 1 || days > 30 { days = 7 } // From today, in the database's own day. utils.DBToday rather than // time.Now(): the column holds IST wall-clock digits, so a UTC-derived // boundary would drop today's row for five and a half hours each evening. from := utils.DBToday() to := from.AddDate(0, 0, days) type row struct { Zone string `json:"zone"` Forday time.Time `json:"forday"` Expectedbookings int `json:"expectedbookings"` Model string `json:"model"` Reason string `json:"reason"` Observations int `json:"observations"` Baselinemae *float64 `json:"baselinemae"` Modelmae *float64 `json:"modelmae"` Generatedat time.Time `json:"generatedat"` // Ridersonduty is the live count for that zone — the same // availabilitystatus set the batch-assign solver treats as working, so // the gap is measured against the riders that could actually take work. Ridersonduty int `json:"ridersonduty"` // Capacity is riders × the per-rider cap the batch solver uses, so the // gap is in the same unit the assignment path thinks in. Capacity int `json:"capacity"` Gap int `json:"gap"` } var rows []row // ORDER BY gap, not f.gap. "gap" is a computed alias in the SELECT list, // not a column of f, and qualifying it with the table alias is an error // Postgres raises at execution time — so this endpoint 500s on every call // rather than failing to compile. Caught by running the query, not by go vet. // // (And the explanation lives here rather than as a -- comment inside the // query: the SQL is a backtick-delimited raw string, so a backtick in a // comment terminates it. That mistake cost a build.) // Riders are joined by the hub's pincode prefix, because a rider belongs to // a hub and a zone IS a pincode prefix. Zones with a forecast and no hub // come back with zero riders rather than being dropped — "we expect 40 and // have nobody" is the single most important row this endpoint can return, // and an inner join would hide it. if err := db.DB.Raw(` SELECT f.zone, f.forday, f.expectedbookings, f.model, f.reason, f.observations, f.baselinemae, f.modelmae, f.generatedat, COALESCE(r.on_duty, 0) AS ridersonduty, COALESCE(r.on_duty, 0) * ? AS capacity, f.expectedbookings - COALESCE(r.on_duty, 0) * ? AS gap FROM demandforecast f LEFT JOIN ( SELECT left(h.pincode, 3) AS zone, count(*) AS on_duty FROM milerprofiles mp JOIN hubs h ON h.hubid = mp.hubid WHERE mp.availabilitystatus IN ? GROUP BY 1 ) r ON r.zone = f.zone WHERE f.forday >= ? AND f.forday < ? ORDER BY f.forday ASC, gap DESC`, defaultBatchAssignCapPerRider, defaultBatchAssignCapPerRider, constants.MilerWorkingStatuses, from, to, ).Scan(&rows).Error; err != nil { utils.Error("GetDemandForecast: query failed", "error", err) return utils.Internal(c, "failed to read the forecast") } // Said explicitly rather than left for the reader to infer from an empty // array: no forecast and a broken forecast look identical otherwise. note := "" if len(rows) == 0 { note = "No forecast has been generated for this window. The engine's forecast job writes it; check that AI_engine is running and has enough delivery history." } return utils.OK(c, fiber.Map{ "days": days, "from": from, "capperrider": defaultBatchAssignCapPerRider, "zones": rows, "note": note, }) }