Files
doormile_backend/controllers/forecastController.go

193 lines
7.3 KiB
Go
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.
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,
})
}